Skip to content
76 changes: 76 additions & 0 deletions apps/web-backend/api/models/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
"""

from datetime import UTC, datetime
from typing import Literal

from core.database import Base
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import relationship

Expand All @@ -26,6 +30,18 @@ class Workspace(Base):
"""A tenant boundary owning specs/runs/repositories, with role-based members."""

__tablename__ = "workspaces"
__table_args__ = (
# At most one "Personal" workspace per owner (partial unique index) — this
# makes get_or_create_personal_workspace race-safe under concurrent
# register/login. Other workspace names are unconstrained.
Index(
"uq_personal_workspace_per_owner",
"owner_id",
unique=True,
sqlite_where=text("name = 'Personal'"),
postgresql_where=text("name = 'Personal'"),
),
)

id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
Expand Down Expand Up @@ -89,3 +105,63 @@ def __repr__(self) -> str:
f"<WorkspaceUser(workspace_id={self.workspace_id}, "
f"user_id={self.user_id}, role={self.role!r})>"
)


# Pydantic models for API requests and responses


class WorkspaceCreateRequest(BaseModel):
"""Request model for creating a workspace (team mode)."""

name: str = Field(..., min_length=1, max_length=255, description="Workspace name")


class WorkspaceResponse(BaseModel):
"""Response model for a workspace, including the caller's role in it."""

id: int = Field(..., description="Workspace ID")
name: str = Field(..., description="Workspace name")
role: str = Field(..., description="Caller's role: owner/editor/viewer")
created_at: datetime = Field(..., description="Creation timestamp")

model_config = ConfigDict(from_attributes=True)


class WorkspaceListResponse(BaseModel):
"""Response model for the list of workspaces the caller can access."""

workspaces: list[WorkspaceResponse] = Field(
default_factory=list, description="Accessible workspaces"
)
cloud_mode: str = Field(..., description="Deployment mode: single or team")


class WorkspaceMemberAddRequest(BaseModel):
"""Request model for adding a member to a workspace."""

user_id: int = Field(..., description="Id of the user to add")
role: Literal["owner", "editor", "viewer"] = Field(
"viewer", description="Role to grant"
)


class WorkspaceMemberUpdateRequest(BaseModel):
"""Request model for changing a member's role."""

role: Literal["owner", "editor", "viewer"] = Field(..., description="New role")


class WorkspaceMemberResponse(BaseModel):
"""Response model for a single workspace member."""

user_id: int = Field(..., description="Member user id")
email: str = Field(..., description="Member email")
role: str = Field(..., description="Member role: owner/editor/viewer")


class WorkspaceMemberListResponse(BaseModel):
"""Response model for the members of a workspace."""

members: list[WorkspaceMemberResponse] = Field(
default_factory=list, description="Workspace members (owner first)"
)
7 changes: 7 additions & 0 deletions apps/web-backend/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from core.database import get_db
from core.security import create_access_token
from fastapi import APIRouter, Depends, HTTPException, status
from services.workspace_service import get_or_create_personal_workspace
from sqlalchemy.orm import Session

from api.models.user import (
Expand Down Expand Up @@ -105,6 +106,9 @@ async def register_user(

logger.info("New user registered: id=%s", user.id)

# Bootstrap the user's Personal workspace (single-mode default).
get_or_create_personal_workspace(db, user.id)

return _build_token_response(user)


Expand Down Expand Up @@ -157,4 +161,7 @@ async def login_user(

logger.info("User logged in: %s", user.email)

# Ensure a Personal workspace exists (idempotent; backfills pre-C2 accounts).
get_or_create_personal_workspace(db, user.id)

return _build_token_response(user)
220 changes: 220 additions & 0 deletions apps/web-backend/api/routes/workspaces.py
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)
Comment thread
OBenner marked this conversation as resolved.
async def list_workspaces(
auth: dict = Depends(require_auth),

Check warning on line 73 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_a&open=AZ7-D7qEkXMtfYKNqT_a&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 74 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_b&open=AZ7-D7qEkXMtfYKNqT_b&pullRequest=367
):
"""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)
Comment thread
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_d&open=AZ7-D7qEkXMtfYKNqT_d&pullRequest=367
auth: dict = Depends(require_auth),

Check warning on line 99 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_h&open=AZ7-D7qEkXMtfYKNqT_h&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 100 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_i&open=AZ7-D7qEkXMtfYKNqT_i&pullRequest=367
):
"""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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_e&open=AZ7-D7qEkXMtfYKNqT_e&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 112 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-D7qEkXMtfYKNqT_f&open=AZ7-D7qEkXMtfYKNqT_f&pullRequest=367
):
"""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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO6&open=AZ7-LKUQX7SLiqjVYIO6&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 134 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO7&open=AZ7-LKUQX7SLiqjVYIO7&pullRequest=367
):
"""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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO8&open=AZ7-LKUQX7SLiqjVYIO8&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 159 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO9&open=AZ7-LKUQX7SLiqjVYIO9&pullRequest=367
):
"""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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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-backend

Repository: OBenner/Auto-Coding

Length of output: 6743


🏁 Script executed:

head -35 apps/web-backend/api/routes/workspaces.py

Repository: OBenner/Auto-Coding

Length of output: 1136


🏁 Script executed:

sed -n '126,135p' apps/web-backend/services/workspace_service.py

Repository: OBenner/Auto-Coding

Length of output: 501


🏁 Script executed:

grep -n "add_member" apps/web-backend/api/routes/workspaces.py

Repository: 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.py

Repository: OBenner/Auto-Coding

Length of output: 954


🏁 Script executed:

sed -n '1,45p' apps/web-backend/api/routes/workspaces.py

Repository: 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 add_member() at line 183 will trigger a database IntegrityError, resulting in a 500 Internal Server Error instead of a clean 409 Conflict. Wrap the insertion in a transaction handler to catch the error, rollback, and return the proper status code.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-backend/api/routes/workspaces.py` around lines 177 - 182, The
duplicate-member check in the workspace member route is still race-prone because
concurrent requests can both pass get_membership() before add_member() commits.
Update the handler around add_member() to run in a transaction and catch the
database IntegrityError from the insert; on that failure, roll back and return
the same 409 Conflict response instead of letting it surface as a 500. Keep the
existing get_membership() pre-check, but make add_member() the authoritative
commit-time guard in this route.



@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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO-&open=AZ7-LKUQX7SLiqjVYIO-&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 193 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIO_&open=AZ7-LKUQX7SLiqjVYIO_&pullRequest=367
):
"""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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIPA&open=AZ7-LKUQX7SLiqjVYIPA&pullRequest=367
db: Session = Depends(get_db),

Check warning on line 212 in apps/web-backend/api/routes/workspaces.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ7-LKUQX7SLiqjVYIPB&open=AZ7-LKUQX7SLiqjVYIPB&pullRequest=367
):
"""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)
8 changes: 8 additions & 0 deletions apps/web-backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def __init__(self):
self.PORT: int = int(os.getenv("PORT", "8000"))
self.DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"

# Cloud deployment mode: "single" (one auto-created "Personal" workspace
# per user) or "team" (many workspaces, explicit workspace_id per request).
self.CLOUD_MODE: str = os.getenv("CLOUD_MODE", "single").strip().lower()

# CORS configuration
cors_origins = os.getenv("CORS_ORIGINS", "")
self.CORS_ORIGINS: list[str] = [
Expand Down Expand Up @@ -92,6 +96,10 @@ def _validate(self):
"SECRET_KEY must be set to a secure value in production. "
"Set DEBUG=false only when SECRET_KEY is properly configured."
)
if self.CLOUD_MODE not in ("single", "team"):
raise ValueError(
f"CLOUD_MODE must be 'single' or 'team', got {self.CLOUD_MODE!r}."
)


@lru_cache
Expand Down
Loading
Loading