1010from app .api .routes .workspaces .schema import (
1111 InviteOut ,
1212 InvitePreviewOut ,
13+ KeyAccessRow ,
1314 MemberOut ,
1415 ToolPermissionMatrixOut ,
1516 WorkspaceDetailOut ,
17+ WorkspaceKeyOut ,
1618 WorkspaceOut ,
1719)
1820from app .core .rbac import ALL_TOOL_SLUGS , ROLES , can_access_tool , resolve_tool_matrix , role_meets
2123from 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+
6880async 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+
263294async 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