-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cloud): workspace bootstrap, resolution & management API (C2) #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
7f31d5a
8b8666c
9c03dfe
957baf0
c365a7e
89d0505
6ece550
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| """ | ||
| Workspace management API (Track C — cloud multitenancy). | ||
|
|
||
| Lets the frontend list the workspaces a user can access, resolve the current one | ||
| (the workspace switcher), and create workspaces in team mode. Access is enforced | ||
| via core/permissions.py; the single-mode "Personal" workspace is auto-created. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| from core.config import settings | ||
| from core.database import get_db | ||
| from core.permissions import ( | ||
| WorkspaceRole, | ||
| get_current_workspace, | ||
| require_workspace_access, | ||
| user_role_in_workspace, | ||
| ) | ||
| from core.security import require_auth | ||
| from fastapi import APIRouter, Depends, HTTPException, status | ||
| from services.workspace_service import ( | ||
| add_member, | ||
| create_workspace, | ||
| get_membership, | ||
| list_accessible_workspaces, | ||
| list_workspace_members, | ||
| remove_member, | ||
| update_member_role, | ||
| ) | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from api.models.user import User | ||
| from api.models.workspace import ( | ||
| Workspace, | ||
| WorkspaceCreateRequest, | ||
| WorkspaceListResponse, | ||
| WorkspaceMemberAddRequest, | ||
| WorkspaceMemberListResponse, | ||
| WorkspaceMemberResponse, | ||
| WorkspaceMemberUpdateRequest, | ||
| WorkspaceResponse, | ||
| WorkspaceUser, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| router = APIRouter(prefix="/api/workspaces", tags=["workspaces"]) | ||
|
|
||
|
|
||
| def _require_user_id(auth: dict) -> int: | ||
| """Return the integer user id from the token claims, or raise 403.""" | ||
| sub = auth.get("sub") | ||
| if sub is None or not str(sub).isdigit(): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="No authenticated user in token", | ||
| ) | ||
| return int(sub) | ||
|
|
||
|
|
||
| def _to_response(workspace: Workspace, role: WorkspaceRole) -> WorkspaceResponse: | ||
| """Build a WorkspaceResponse including the caller's role string.""" | ||
| return WorkspaceResponse( | ||
| id=workspace.id, | ||
| name=workspace.name, | ||
| role=role.value, | ||
| created_at=workspace.created_at, | ||
| ) | ||
|
|
||
|
|
||
| @router.get("", response_model=WorkspaceListResponse) | ||
| async def list_workspaces( | ||
| auth: dict = Depends(require_auth), | ||
|
Check warning on line 73 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 74 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """List every workspace the caller can access, with their role in each.""" | ||
| user_id = _require_user_id(auth) | ||
| workspaces = list_accessible_workspaces(db, user_id) | ||
| # Resolve roles with a single membership query (avoids an N+1 over | ||
| # user_role_in_workspace): owners are detected from owner_id directly. | ||
| member_roles = { | ||
| wu.workspace_id: wu.role | ||
| for wu in db.query(WorkspaceUser).filter(WorkspaceUser.user_id == user_id) | ||
| } | ||
| items: list[WorkspaceResponse] = [] | ||
| for ws in workspaces: | ||
| role = ( | ||
| WorkspaceRole.OWNER | ||
| if ws.owner_id == user_id | ||
| else WorkspaceRole(member_roles.get(ws.id, WorkspaceRole.VIEWER.value)) | ||
| ) | ||
| items.append(_to_response(ws, role)) | ||
| return WorkspaceListResponse(workspaces=items, cloud_mode=settings.CLOUD_MODE) | ||
|
OBenner marked this conversation as resolved.
|
||
|
|
||
|
|
||
| @router.get("/current", response_model=WorkspaceResponse) | ||
| async def current_workspace( | ||
| workspace: Workspace = Depends(get_current_workspace), | ||
|
Check warning on line 98 in apps/web-backend/api/routes/workspaces.py
|
||
| auth: dict = Depends(require_auth), | ||
|
Check warning on line 99 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 100 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """Resolve the current workspace (Personal in single mode; ?workspace_id in team).""" | ||
| user_id = _require_user_id(auth) | ||
| role = user_role_in_workspace(db, user_id, workspace.id) or WorkspaceRole.VIEWER | ||
| return _to_response(workspace, role) | ||
|
|
||
|
|
||
| @router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED) | ||
| async def create_workspace_endpoint( | ||
| request: WorkspaceCreateRequest, | ||
| auth: dict = Depends(require_auth), | ||
|
Check warning on line 111 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 112 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """Create a workspace owned by the caller (used in team mode).""" | ||
| user_id = _require_user_id(auth) | ||
| workspace = create_workspace(db, owner_id=user_id, name=request.name) | ||
| return _to_response(workspace, WorkspaceRole.OWNER) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Member management (team mode) — the first real consumer of | ||
| # require_workspace_access: listing needs >= viewer, mutations need owner. | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def _member_response(user_id: int, email: str, role: str) -> WorkspaceMemberResponse: | ||
| return WorkspaceMemberResponse(user_id=user_id, email=email, role=role) | ||
|
|
||
|
|
||
| @router.get("/{workspace_id}/members", response_model=WorkspaceMemberListResponse) | ||
| async def list_members( | ||
| workspace_id: int, | ||
| _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.VIEWER)), | ||
|
Check warning on line 133 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 134 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """List a workspace's members (owner first). Requires >= viewer access.""" | ||
| workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first() | ||
| if workspace is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found" | ||
| ) | ||
| members = [_member_response(workspace.owner_id, workspace.owner.email, "owner")] | ||
| members.extend( | ||
| _member_response(wu.user_id, wu.user.email, wu.role) | ||
| for wu in list_workspace_members(db, workspace_id) | ||
| ) | ||
| return WorkspaceMemberListResponse(members=members) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/{workspace_id}/members", | ||
| response_model=WorkspaceMemberResponse, | ||
| status_code=status.HTTP_201_CREATED, | ||
| ) | ||
| async def add_workspace_member( | ||
| workspace_id: int, | ||
| request: WorkspaceMemberAddRequest, | ||
| _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), | ||
|
Check warning on line 158 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 159 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """Add a member to a workspace. Requires owner access.""" | ||
| workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first() | ||
| if workspace is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found" | ||
| ) | ||
| if request.user_id == workspace.owner_id: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="The workspace owner already has access", | ||
| ) | ||
| user = db.query(User).filter(User.id == request.user_id).first() | ||
| if user is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, detail="User not found" | ||
| ) | ||
| if get_membership(db, workspace_id, request.user_id) is not None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_409_CONFLICT, detail="User is already a member" | ||
| ) | ||
| membership = add_member(db, workspace_id, request.user_id, request.role) | ||
| return _member_response(membership.user_id, user.email, membership.role) | ||
|
Comment on lines
+177
to
+182
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -n -C3 'UniqueConstraint\("workspace_id", "user_id"|def add_member|IntegrityError|add_workspace_member' apps/web-backendRepository: OBenner/Auto-Coding Length of output: 6743 🏁 Script executed: head -35 apps/web-backend/api/routes/workspaces.pyRepository: OBenner/Auto-Coding Length of output: 1136 🏁 Script executed: sed -n '126,135p' apps/web-backend/services/workspace_service.pyRepository: OBenner/Auto-Coding Length of output: 501 🏁 Script executed: grep -n "add_member" apps/web-backend/api/routes/workspaces.pyRepository: OBenner/Auto-Coding Length of output: 257 🏁 Script executed: head -45 apps/web-backend/api/routes/workspaces.py | grep -A 5 "from api.models"Repository: OBenner/Auto-Coding Length of output: 359 🏁 Script executed: sed -n '170,190p' apps/web-backend/api/routes/workspaces.pyRepository: OBenner/Auto-Coding Length of output: 954 🏁 Script executed: sed -n '1,45p' apps/web-backend/api/routes/workspaces.pyRepository: OBenner/Auto-Coding Length of output: 1372 Handle the duplicate-member race at commit time. The pre-check at line 177 is subject to a race condition. If concurrent requests pass the check simultaneously, the second call to Suggested implementation+from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, status
from services.workspace_service import (
add_member,
@@
membership = add_member(db, workspace_id, request.user_id, request.role)
+ try:
+ membership = add_member(db, workspace_id, request.user_id, request.role)
+ except IntegrityError:
+ db.rollback()
+ if get_membership(db, workspace_id, request.user_id) is not None:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT, detail="User is already a member"
+ )
+ raise
return _member_response(membership.user_id, user.email, membership.role)🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| @router.patch( | ||
| "/{workspace_id}/members/{user_id}", response_model=WorkspaceMemberResponse | ||
| ) | ||
| async def update_workspace_member( | ||
| workspace_id: int, | ||
| user_id: int, | ||
| request: WorkspaceMemberUpdateRequest, | ||
| _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), | ||
|
Check warning on line 192 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 193 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """Change a member's role. Requires owner access.""" | ||
| membership = get_membership(db, workspace_id, user_id) | ||
| if membership is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, detail="Membership not found" | ||
| ) | ||
| membership = update_member_role(db, membership, request.role) | ||
| return _member_response(membership.user_id, membership.user.email, membership.role) | ||
|
|
||
|
|
||
| @router.delete( | ||
| "/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT | ||
| ) | ||
| async def remove_workspace_member( | ||
| workspace_id: int, | ||
| user_id: int, | ||
| _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), | ||
|
Check warning on line 211 in apps/web-backend/api/routes/workspaces.py
|
||
| db: Session = Depends(get_db), | ||
|
Check warning on line 212 in apps/web-backend/api/routes/workspaces.py
|
||
| ): | ||
| """Remove a member from a workspace. Requires owner access.""" | ||
| membership = get_membership(db, workspace_id, user_id) | ||
| if membership is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, detail="Membership not found" | ||
| ) | ||
| remove_member(db, membership) | ||
Uh oh!
There was an error while loading. Please reload this page.