Skip to content

Commit 08886f3

Browse files
committed
Workspaces, RBAC, invites
1 parent 49f81d0 commit 08886f3

39 files changed

Lines changed: 1427 additions & 155 deletions

apps/backend/app/api/routes/auth/api.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,18 @@
1212
from app.api.routes.auth.schema import (
1313
BackupCodeDataOut,
1414
BackupCodeLookupRequest,
15+
KeypairOut,
16+
KeypairSetupRequest,
1517
MasterVaultOut,
1618
MasterVaultSetupRequest,
1719
OkResponse,
1820
SessionRequest,
1921
StoreBackupCodesRequest,
2022
UserProfileResponse,
23+
UserPublicKeyOut,
2124
UpdateProfileRequest,
2225
)
26+
from app.api.routes.workspaces.services import shares_workspace
2327
from app.api.routes.auth.services import get_current_uid, get_current_user, verify_id_token, _token_cache_key
2428
from app.core.cache import bump_version, cache_invalidate
2529
from app.api.routes.auth.tokens import (
@@ -33,10 +37,13 @@
3337
complete_onboarding,
3438
find_uid_by_refresh_hash,
3539
get_backup_code_by_id,
40+
get_keypair,
3641
get_master_vault,
42+
get_public_key,
3743
get_user_doc,
3844
mark_backup_code_used,
3945
set_backup_codes,
46+
set_keypair,
4047
set_master_vault,
4148
set_refresh_token_hash,
4249
upsert_user_from_firebase_claims,
@@ -327,6 +334,60 @@ async def setup_master_vault_endpoint(
327334
return MasterVaultOut(**vault)
328335

329336

337+
# ── Per-user keypair (envelope encryption for team secrets) ────────────────────
338+
339+
340+
@router.get(
341+
"/keypair",
342+
response_model=KeypairOut,
343+
summary="Get the current user's keypair (public + master-key-wrapped private blob)",
344+
)
345+
async def get_keypair_endpoint(uid: Annotated[str, Depends(get_current_uid)]) -> KeypairOut:
346+
kp = await get_keypair(uid)
347+
if not kp:
348+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keypair not configured.")
349+
return KeypairOut(**kp)
350+
351+
352+
@router.post(
353+
"/keypair",
354+
response_model=KeypairOut,
355+
status_code=status.HTTP_201_CREATED,
356+
summary="Set up the user's keypair once (public key + wrapped private key; both opaque to server)",
357+
)
358+
@limiter.limit("3/minute")
359+
async def setup_keypair_endpoint(
360+
request: Request,
361+
payload: KeypairSetupRequest,
362+
uid: Annotated[str, Depends(get_current_uid)],
363+
) -> KeypairOut:
364+
keypair = {
365+
"public_key": payload.public_key,
366+
"enc_private_key": payload.enc_private_key.model_dump(),
367+
"createdAt": int(time.time() * 1000),
368+
}
369+
stored = await set_keypair(uid, keypair)
370+
if not stored:
371+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Keypair already configured.")
372+
return KeypairOut(public_key=keypair["public_key"], enc_private_key=payload.enc_private_key)
373+
374+
375+
@router.get(
376+
"/users/{target_uid}/public-key",
377+
response_model=UserPublicKeyOut,
378+
summary="Get a member's public key (to wrap a workspace DEK to them)",
379+
)
380+
async def get_user_public_key_endpoint(
381+
target_uid: str, uid: Annotated[str, Depends(get_current_uid)]
382+
) -> UserPublicKeyOut:
383+
# Public keys are non-secret. Require sharing at least one workspace with the
384+
# target to avoid an enumeration surface (self always allowed).
385+
if target_uid != uid and not await shares_workspace(uid, target_uid):
386+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not permitted.")
387+
pub = await get_public_key(target_uid)
388+
return UserPublicKeyOut(uid=target_uid, public_key=pub)
389+
390+
330391
# ── Backup codes ──────────────────────────────────────────────────────────────
331392

332393

apps/backend/app/api/routes/auth/schema.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,38 @@ class MasterVaultOut(BaseModel):
157157
createdAt: int
158158

159159

160+
class WrappedPrivateKey(BaseModel):
161+
"""PKCS8 private key wrapped (AES-GCM) with the user's master key. Opaque to the server."""
162+
163+
model_config = ConfigDict(extra="ignore")
164+
165+
v: int = 1
166+
alg: str = "AES-GCM"
167+
encrypted: str = Field(min_length=1)
168+
iv: str = Field(min_length=1)
169+
170+
171+
class KeypairSetupRequest(BaseModel):
172+
"""Client sends its SPKI public key (non-secret) + master-key-wrapped private key."""
173+
174+
public_key: str = Field(min_length=1)
175+
enc_private_key: WrappedPrivateKey
176+
177+
178+
class KeypairOut(BaseModel):
179+
model_config = ConfigDict(extra="ignore")
180+
181+
public_key: str | None = None
182+
enc_private_key: WrappedPrivateKey | None = None
183+
184+
185+
class UserPublicKeyOut(BaseModel):
186+
"""A member's public key (for wrapping a workspace DEK to them). Never includes the private blob."""
187+
188+
uid: str
189+
public_key: str | None = None
190+
191+
160192
# ── Backup codes ──────────────────────────────────────────────────────────────
161193

162194
class BackupCodeEntry(BaseModel):

apps/backend/app/api/routes/auth/users_repo.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,34 @@ async def set_master_vault(uid: str, vault: dict[str, Any]) -> None:
9292
await bump_version(ns="auth_user", uid=uid)
9393

9494

95+
async def get_keypair(uid: str) -> dict[str, Any] | None:
96+
doc = await get_user_doc(uid)
97+
if not doc:
98+
return None
99+
return doc.get("keypair") or None
100+
101+
102+
async def set_keypair(uid: str, keypair: dict[str, Any]) -> bool:
103+
"""Set-once. Returns True if stored, False if a keypair already existed (race-safe)."""
104+
now = create_timestamp()
105+
result = await db_manager.update_one(
106+
USERS,
107+
{"_id": uid, "keypair": {"$exists": False}},
108+
{"$set": {"keypair": keypair, "updated_at": now}},
109+
)
110+
if getattr(result, "modified_count", 0):
111+
await bump_version(ns="auth_user", uid=uid)
112+
return True
113+
return False
114+
115+
116+
async def get_public_key(uid: str) -> str | None:
117+
doc = await get_user_doc(uid)
118+
if not doc:
119+
return None
120+
return (doc.get("keypair") or {}).get("public_key")
121+
122+
95123
async def set_backup_codes(uid: str, codes: list[dict[str, Any]]) -> None:
96124
now = create_timestamp()
97125
await db_manager.update_one(USERS, {"_id": uid}, {"$set": {"backup_codes": codes, "updated_at": now}})

apps/backend/app/api/routes/workspaces/api.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
from fastapi import APIRouter, Depends
1+
from fastapi import APIRouter, Depends, HTTPException, status
22

33
from app.api.routes.auth.schema import UserProfileResponse
44
from app.api.routes.auth.services import get_current_uid, get_current_user
55
from app.api.routes.workspaces import services as ws_svc
66
from app.api.routes.workspaces.schema import (
77
CreateWorkspaceRequest,
8+
GrantKeyRequest,
89
InviteOut,
910
InvitePreviewOut,
1011
InviteRequest,
12+
KeyAccessRow,
1113
MemberOut,
1214
ToolPermissionMatrixOut,
1315
ToolPermissionUpdate,
1416
UpdateRoleRequest,
1517
UpdateWorkspaceRequest,
1618
WorkspaceDetailOut,
19+
WorkspaceKeyOut,
1720
WorkspaceOut,
1821
)
1922

@@ -71,6 +74,11 @@ async def delete_workspace(workspace_id: str, uid: str = Depends(get_current_uid
7174
await ws_svc.delete_workspace(workspace_id)
7275

7376

77+
@router.post("/{workspace_id}/leave", status_code=204, summary="Leave a workspace (any member; not last owner / Personal)")
78+
async def leave_workspace(workspace_id: str, uid: str = Depends(get_current_uid)) -> None:
79+
await ws_svc.leave_workspace(workspace_id, uid)
80+
81+
7482
# ── Members ──────────────────────────────────────────────────────────────────
7583

7684
@router.get("/{workspace_id}/members", response_model=list[MemberOut], summary="List members")
@@ -131,3 +139,29 @@ async def set_tool_permission(
131139
) -> ToolPermissionMatrixOut:
132140
await ws_svc.assert_role(workspace_id, uid, "admin")
133141
return await ws_svc.set_tool_permission(workspace_id, body.role, body.slug, body.enabled)
142+
143+
144+
# ── Envelope-encryption keys (Phase 3) ────────────────────────────────────────
145+
146+
@router.get("/{workspace_id}/my-key", response_model=WorkspaceKeyOut, summary="My wrapped workspace DEK")
147+
async def get_my_key(workspace_id: str, uid: str = Depends(get_current_uid)) -> WorkspaceKeyOut:
148+
await ws_svc.assert_role(workspace_id, uid, "viewer") # any active member
149+
key = await ws_svc.get_my_workspace_key(workspace_id, uid)
150+
if not key:
151+
# 404 = member but no secret access yet (an admin must grant).
152+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No secret access yet.")
153+
return key
154+
155+
156+
@router.post("/{workspace_id}/grant-key", status_code=201, summary="Grant secret access to a member (Admin+)")
157+
async def grant_key(
158+
workspace_id: str, body: GrantKeyRequest, uid: str = Depends(get_current_uid)
159+
) -> None:
160+
await ws_svc.assert_role(workspace_id, uid, "admin")
161+
await ws_svc.grant_key(workspace_id, body.target_uid, body.wrapped_dek, uid)
162+
163+
164+
@router.get("/{workspace_id}/key-access", response_model=list[KeyAccessRow], summary="Who has secret access")
165+
async def key_access(workspace_id: str, uid: str = Depends(get_current_uid)) -> list[KeyAccessRow]:
166+
await ws_svc.assert_role(workspace_id, uid, "viewer")
167+
return await ws_svc.list_key_access(workspace_id)

apps/backend/app/api/routes/workspaces/schema.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,27 @@ class ToolPermissionMatrixOut(BaseModel):
7474
matrix: dict[str, dict[str, bool]]
7575
overrides: dict[str, dict[str, bool]] = Field(default_factory=dict)
7676
roles: list[str] = Field(default_factory=lambda: list(ROLES))
77+
78+
79+
# ── Envelope encryption (Phase 3) ─────────────────────────────────────────────
80+
81+
82+
class WorkspaceKeyOut(BaseModel):
83+
"""The caller's RSA-wrapped workspace DEK (opaque ciphertext)."""
84+
85+
wrapped_dek: str
86+
alg: str = "RSA-OAEP-256"
87+
v: int = 1
88+
89+
90+
class GrantKeyRequest(BaseModel):
91+
target_uid: str = Field(min_length=1)
92+
wrapped_dek: str = Field(min_length=1) # DEK RSA-wrapped to target's public key
93+
94+
95+
class KeyAccessRow(BaseModel):
96+
uid: str | None = None
97+
email: str | None = None
98+
display_name: str | None = None
99+
role: str
100+
has_key: bool

apps/backend/app/api/routes/workspaces/services.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
from app.api.routes.workspaces.schema import (
1111
InviteOut,
1212
InvitePreviewOut,
13+
KeyAccessRow,
1314
MemberOut,
1415
ToolPermissionMatrixOut,
1516
WorkspaceDetailOut,
17+
WorkspaceKeyOut,
1618
WorkspaceOut,
1719
)
1820
from app.core.rbac import ALL_TOOL_SLUGS, ROLES, can_access_tool, resolve_tool_matrix, role_meets
@@ -21,6 +23,7 @@
2123
from app.utils.collection_name import (
2224
USERS,
2325
WORKSPACE_INVITES,
26+
WORKSPACE_KEYS,
2427
WORKSPACE_MEMBERS,
2528
WORKSPACES,
2629
)
@@ -65,6 +68,15 @@ async def list_memberships(uid: str) -> list[dict[str, Any]]:
6568
)
6669

6770

71+
async def shares_workspace(uid_a: str, uid_b: str) -> bool:
72+
"""True if both users are active members of at least one common workspace."""
73+
a = {m["workspace_id"] for m in await list_memberships(uid_a)}
74+
if not a:
75+
return False
76+
b = {m["workspace_id"] for m in await list_memberships(uid_b)}
77+
return bool(a & b)
78+
79+
6880
async def count_owners(workspace_id: str) -> int:
6981
return await db_manager.count_documents(
7082
WORKSPACE_MEMBERS, {"workspace_id": workspace_id, "role": "owner", "status": "active"}
@@ -260,6 +272,25 @@ async def update_member_role(workspace_id: str, target_uid: str, role: str) -> N
260272
)
261273

262274

275+
async def leave_workspace(workspace_id: str, uid: str) -> None:
276+
"""A member removes themselves. Cannot leave Personal or as the last owner."""
277+
member = await get_membership(workspace_id, uid)
278+
if not member:
279+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="You are not a member.")
280+
ws = await get_workspace(workspace_id)
281+
if ws and ws.get("is_personal"):
282+
raise HTTPException(
283+
status_code=status.HTTP_400_BAD_REQUEST, detail="You cannot leave your Personal workspace."
284+
)
285+
if member["role"] == "owner" and await count_owners(workspace_id) <= 1:
286+
raise HTTPException(
287+
status_code=status.HTTP_400_BAD_REQUEST,
288+
detail="Transfer ownership to someone else before leaving — you're the last owner.",
289+
)
290+
await db_manager.delete_one(WORKSPACE_MEMBERS, {"workspace_id": workspace_id, "uid": uid})
291+
await db_manager.delete_one(WORKSPACE_KEYS, {"workspace_id": workspace_id, "uid": uid})
292+
293+
263294
async def remove_member(workspace_id: str, target_uid: str) -> None:
264295
member = await get_membership(workspace_id, target_uid)
265296
if not member:
@@ -278,6 +309,12 @@ async def remove_member(workspace_id: str, target_uid: str) -> None:
278309
await db_manager.delete_one(
279310
WORKSPACE_MEMBERS, {"workspace_id": workspace_id, "uid": target_uid}
280311
)
312+
# Revoke future secret access. ponytail: the DEK itself is NOT rotated here —
313+
# a removed member who cached the DEK could still read old secrets. The manual
314+
# "Rotate workspace key" admin action covers the untrusted-member case.
315+
await db_manager.delete_one(
316+
WORKSPACE_KEYS, {"workspace_id": workspace_id, "uid": target_uid}
317+
)
281318

282319

283320
# ── Tool permissions ─────────────────────────────────────────────────────────
@@ -438,3 +475,51 @@ async def accept_invite(token: str, uid: str, user_email: str | None) -> Workspa
438475
ws = await get_workspace(workspace_id)
439476
role = existing["role"] if existing else invite["role"]
440477
return _workspace_out(ws or {"_id": workspace_id, "name": ""}, role)
478+
479+
480+
# ── Envelope-encryption keys (Phase 3) ────────────────────────────────────────
481+
# The server only ever stores opaque RSA-wrapped DEK ciphertext — never the DEK.
482+
483+
async def get_my_workspace_key(workspace_id: str, uid: str) -> WorkspaceKeyOut | None:
484+
doc = await db_manager.find_one(WORKSPACE_KEYS, {"workspace_id": workspace_id, "uid": uid})
485+
if not doc:
486+
return None
487+
return WorkspaceKeyOut(
488+
wrapped_dek=doc["wrapped_dek"], alg=doc.get("alg", "RSA-OAEP-256"), v=int(doc.get("v", 1))
489+
)
490+
491+
492+
async def grant_key(workspace_id: str, target_uid: str, wrapped_dek: str, granted_by: str) -> None:
493+
"""Store the workspace DEK wrapped to a member's public key. Idempotent upsert."""
494+
if not await get_membership(workspace_id, target_uid):
495+
raise HTTPException(
496+
status_code=status.HTTP_404_NOT_FOUND,
497+
detail="That user is not an active member of this workspace.",
498+
)
499+
await db_manager.update_one(
500+
WORKSPACE_KEYS,
501+
{"workspace_id": workspace_id, "uid": target_uid},
502+
{
503+
"$set": {"wrapped_dek": wrapped_dek, "alg": "RSA-OAEP-256", "v": 1, "granted_by": granted_by},
504+
"$setOnInsert": {"_id": new_id(), "createdAt": create_timestamp()},
505+
},
506+
upsert=True,
507+
)
508+
509+
510+
async def list_key_access(workspace_id: str) -> list[KeyAccessRow]:
511+
members = await list_members(workspace_id)
512+
key_docs = await db_manager.find(
513+
WORKSPACE_KEYS, {"workspace_id": workspace_id}, projection={"uid": 1}
514+
)
515+
have = {d["uid"] for d in key_docs}
516+
return [
517+
KeyAccessRow(
518+
uid=m.uid,
519+
email=m.email,
520+
display_name=m.display_name,
521+
role=m.role,
522+
has_key=m.uid in have,
523+
)
524+
for m in members
525+
]

0 commit comments

Comments
 (0)