feat(cloud): workspace bootstrap, resolution & management API (C2)#367
feat(cloud): workspace bootstrap, resolution & management API (C2)#367OBenner wants to merge 7 commits into
Conversation
Second slice of Track C — the workspace-context layer that makes the C1 models usable. - CLOUD_MODE config flag (single|team, validated; default single). - services/workspace_service.py: get_or_create_personal_workspace (idempotent single-mode default), list_accessible_workspaces (owned ∪ member), create_workspace. - core/permissions.py: get_current_workspace dependency — single mode resolves the caller's auto-created Personal workspace; team mode requires ?workspace_id and enforces >= viewer access. Extracted a shared _current_user_id helper (reused by require_workspace_access). - api/routes/workspaces.py: GET /api/workspaces (list with role), GET /api/workspaces/current, POST /api/workspaces; registered in main.py. - Personal-workspace bootstrap hooked into register + login (idempotent; backfills pre-C2 accounts). - Tests: bootstrap idempotency, owned∪member listing, single/team resolution (+400/403), and an end-to-end HTTP test of the router. Restores the owner-access test's membership-absence assertion dropped by the C1 squash. 11 workspace tests pass; existing auth/user route tests (49) unaffected; ruff clean. No schema change: per-resource scoping (repos/runs) lands as each gains DB persistence (C3/C6); today only 'repositories' is a DB model and has no CRUD routes yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds workspace schemas, cloud-mode validation, persistence helpers, workspace resolution, new workspace routes, app wiring, and tests for bootstrap, access, and member management. ChangesWorkspace multitenancy
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with 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.
Inline comments:
In `@apps/web-backend/api/routes/workspaces.py`:
- Around line 63-67: The workspace listing in the route that builds
WorkspaceListResponse has an N+1 query pattern because the loop over
list_accessible_workspaces calls user_role_in_workspace for each workspace. Fix
this by deriving the role without re-fetching each row in the loop, using
ws.owner_id to detect OWNER and a single batched membership lookup keyed by
workspace_id for the remaining workspaces, then pass the resolved role into
_to_response.
- Line 56: The FastAPI route signatures in workspaces.py should be updated to
match the newer idioms flagged by SonarCloud: remove the redundant
response_model from the `@router.get/`@router.post decorators where the return
type annotation already defines the response, and convert any dependency
parameters that currently use default-value Depends(...) to Annotated[T,
Depends(...)] in the affected route functions. Apply this consistently to the
route handlers identified by the decorated endpoints and their dependency
parameters so the signatures are idiomatic without changing behavior.
In `@apps/web-backend/core/permissions.py`:
- Around line 149-157: The workspace fetch in the team-mode permission path is
duplicated because `check_workspace_access` already performs the `Workspace`
lookup via `user_role_in_workspace`. Update the resolver around the existing
`check_workspace_access` call to avoid calling
`db.query(Workspace).filter(...).first()` up front; instead, perform the access
check first and only fetch/reuse the `Workspace` after it succeeds, while
keeping the same `HTTPException` behavior for denied access.
In `@apps/web-backend/services/workspace_service.py`:
- Around line 33-52: The get_or_create_personal_workspace flow is vulnerable to
duplicate inserts because the existing lookup and insert are not atomic. Update
the Workspace creation path to be idempotent by using a database-level
uniqueness guarantee on the personal workspace identity (for example, owner_id
plus name) and handle conflicts in get_or_create_personal_workspace, or switch
the Workspace insert to an upsert/ON CONFLICT pattern so concurrent calls cannot
create multiple Personal rows for the same user.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4deee8f8-d264-4a2c-99da-7fe59fc69e9c
📒 Files selected for processing (8)
apps/web-backend/api/models/workspace.pyapps/web-backend/api/routes/users.pyapps/web-backend/api/routes/workspaces.pyapps/web-backend/core/config.pyapps/web-backend/core/permissions.pyapps/web-backend/main.pyapps/web-backend/services/workspace_service.pyapps/web-backend/tests/test_workspaces.py
… (C2 review) - workspace_service.create_workspace: stop logging the user-supplied name (CodeQL log-injection); log id + owner_id only. - workspaces.list_workspaces: replace the per-row user_role_in_workspace call (N+1) with a single membership query; owners derived from owner_id. - permissions.get_current_workspace (team mode): check access first, fetch the workspace row only on success (no wasted lookup on denial). - workspaces routes: drop redundant return-type annotations (keep response_model= in the decorator, matching the rest of the codebase) to clear the SonarCloud duplication hint. Skipped (with reason): Annotated[...] deps (whole codebase uses = Depends; trivial/stylistic) and a unique-constraint for concurrent Personal-workspace creation (needs a migration, out of C2's no-schema scope; low-probability + reads self-heal). 11 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rounds out the workspace API with team-member management — and the first real consumer of C1's require_workspace_access dependency (listing needs >= viewer, mutations need owner).
- Endpoints (api/routes/workspaces.py): GET /api/workspaces/{id}/members (owner first), POST (add by user_id+role), PATCH /{user_id} (change role), DELETE /{user_id} (remove). Validates: unknown workspace/user -> 404, adding the owner -> 400, duplicate member -> 409.
- Service (services/workspace_service.py): list_workspace_members, get_membership, add_member, update_member_role, remove_member.
- Models: WorkspaceMemberAddRequest/UpdateRequest (role validated via Literal) + member response schemas.
- Tests: service CRUD + an end-to-end HTTP flow (add/list/dup-409/owner-400/update/non-owner-403/viewer-can-list/remove-204). 13 workspace tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
list_workspace_members now joinedload's WorkspaceUser.user so the members route reads wu.user.email without a per-row query — same fix as list_workspaces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'Insufficient workspace permissions' literal is now used 3x in permissions.py; hoist it to a module constant. (The remaining Sonar S8410 'use Annotated for deps' hints are left as-is: the whole web-backend uses = Depends(...), per the convention CodeRabbit recorded as a repo learning.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in asserts (C2) - workspace_service.add_member: remove the info log (user_id/role/workspace_id are request-derived → CodeQL log-injection; member-change audit is C3/C7). - tests: extract client.post/get/delete calls out of assert expressions so python -O can't strip the action (CodeQL py/side-effect-in-assert). 13 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p (C2) Closes the concurrent-duplicate edge case flagged in review (previously deferred). Adds a partial unique index uq_personal_workspace_per_owner on workspaces(owner_id) WHERE name='Personal' (model __table_args__ + migration 004; SQLite + Postgres partial-WHERE). get_or_create_personal_workspace is now truly idempotent under concurrency: the race loser catches IntegrityError and reuses the winner's workspace. Test asserts a second 'Personal' is rejected while other names stay unconstrained. Note: this adds the first C2 migration (004), upgrading the slice from no-schema to a single additive index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web-backend/api/routes/workspaces.py (1)
109-117: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winGate team-only workspace management when
CLOUD_MODE=single.
singlemode is defined as one auto-created Personal workspace per user, but these routes still allow creating additional workspaces and sharing/managing members. Add a team-mode guard to the create and member-management endpoints.Proposed fix
+def require_team_mode() -> None: + if settings.CLOUD_MODE != "team": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Workspace management is only available in team mode", + ) + + async def create_workspace_endpoint( request: WorkspaceCreateRequest, + _team_mode: None = Depends(require_team_mode), auth: dict = Depends(require_auth), db: Session = Depends(get_db), ): @@ async def list_members( workspace_id: int, + _team_mode: None = Depends(require_team_mode), _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.VIEWER)), db: Session = Depends(get_db), ): @@ async def add_workspace_member( workspace_id: int, request: WorkspaceMemberAddRequest, + _team_mode: None = Depends(require_team_mode), _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), db: Session = Depends(get_db), ): @@ async def update_workspace_member( workspace_id: int, user_id: int, request: WorkspaceMemberUpdateRequest, + _team_mode: None = Depends(require_team_mode), _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), db: Session = Depends(get_db), ): @@ async def remove_workspace_member( workspace_id: int, user_id: int, + _team_mode: None = Depends(require_team_mode), _role: WorkspaceRole = Depends(require_workspace_access(WorkspaceRole.OWNER)), db: Session = Depends(get_db), ):Also applies to: 130-220
🤖 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 109 - 117, Add a team-mode guard to the workspace creation and member-management routes so they are unavailable when CLOUD_MODE is single. Update create_workspace_endpoint and the related member/sharing handlers in the same routes module to check the cloud mode before calling create_workspace or modifying members, and reject the request with the same auth/validation style used elsewhere in the API. Use the existing workspace route helpers and symbols like create_workspace_endpoint, _require_user_id, and the member-management endpoint functions to place the guard consistently.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@apps/web-backend/api/routes/workspaces.py`:
- Around line 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.
In `@apps/web-backend/migrations/versions/004_unique_personal_workspace.py`:
- Around line 33-42: The `upgrade()` migration in
`004_unique_personal_workspace.py` can fail when duplicate `Personal` workspaces
already exist for the same `owner_id`. Add a cleanup step before
`op.create_index` that identifies duplicate `workspaces` rows for `name =
'Personal'`, keeps one row per `owner_id`, and deletes the older extras so the
unique partial index can be created successfully. Use the
`PERSONAL_WORKSPACE_NAME` and `Workspace` creation path in
`workspace_service.py` as the context for the affected records.
---
Outside diff comments:
In `@apps/web-backend/api/routes/workspaces.py`:
- Around line 109-117: Add a team-mode guard to the workspace creation and
member-management routes so they are unavailable when CLOUD_MODE is single.
Update create_workspace_endpoint and the related member/sharing handlers in the
same routes module to check the cloud mode before calling create_workspace or
modifying members, and reject the request with the same auth/validation style
used elsewhere in the API. Use the existing workspace route helpers and symbols
like create_workspace_endpoint, _require_user_id, and the member-management
endpoint functions to place the guard consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 46c2d3f7-5415-4a73-accd-cef82ab4ce22
📒 Files selected for processing (6)
apps/web-backend/api/models/workspace.pyapps/web-backend/api/routes/workspaces.pyapps/web-backend/core/permissions.pyapps/web-backend/migrations/versions/004_unique_personal_workspace.pyapps/web-backend/services/workspace_service.pyapps/web-backend/tests/test_workspaces.py
| 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) |
There was a problem hiding this comment.
🩺 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 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.
| def upgrade() -> None: | ||
| """Add a partial unique index: at most one 'Personal' workspace per owner.""" | ||
| op.create_index( | ||
| _INDEX_NAME, | ||
| "workspaces", | ||
| ["owner_id"], | ||
| unique=True, | ||
| sqlite_where=sa.text("name = 'Personal'"), | ||
| postgresql_where=sa.text("name = 'Personal'"), | ||
| ) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for any earlier callers/creation of "Personal" workspaces and any dedup migration.
rg -nP --type=py 'PERSONAL_WORKSPACE_NAME|name\s*=\s*["'\'']Personal["'\'']' -C2
echo "----- migrations -----"
fd -e py . --full-path -p 'migrations/versions' --exec rg -nl 'Personal|dedup|delete' {} \;Repository: OBenner/Auto-Coding
Length of output: 157
🏁 Script executed:
#!/bin/bash
# 1. List migration files to understand sequence
echo "=== Migration Files ==="
fd -e py --full-path 'migrations/versions' | head -20
# 2. Search for 'Personal' constant definition
echo "=== Constant Definitions ==="
grep -rn "PERSONAL" --include="*.py" | head -10
# 3. Search for direct "Personal" string usage in non-test python
echo "=== Usage Context ==="
grep -rn "\"Personal\"" --include="*.py" | grep -v test | grep -v __pycache__ | head -15
# 4. Check the specific migration file content
echo "=== Target Migration Content ==="
cat apps/web-backend/migrations/versions/004_unique_personal_workspace.pyRepository: OBenner/Auto-Coding
Length of output: 3098
🌐 Web query:
Albumentations installation command
💡 Result:
To install the current version of the library, known as AlbumentationsX, use the following command: pip install albumentationsx [1][2][3] Important Installation Notes: 1. OpenCV Dependency: Starting with version 2.0.14, OpenCV is no longer installed automatically [1]. You should choose an installation command based on your environment: - For headless environments (e.g., servers, Docker, CI/CD): pip install albumentationsx[headless] [1][2] - For environments requiring GUI support (e.g., local development): pip install opencv-python && pip install albumentationsx [1][2] - For environments needing extra algorithms (OpenCV contrib modules): pip install albumentationsx[contrib] [1] 2. Python Requirements: AlbumentationsX requires Python 3.10 or higher [1][2]. 3. Migration: If you have an older version of the library installed, it is recommended to uninstall the original package before installing AlbumentationsX [4]: pip uninstall albumentations pip install albumentationsx You can verify the installation by running [1]: python -c "import albumentations as A; print(A.version)"
Citations:
- 1: https://albumentations.ai/docs/1-introduction/installation/
- 2: https://github.com/albumentations-team/AlbumentationsX/blob/main/README.md
- 3: https://albumentations.ai/
- 4: https://github.com/albumentations-team/albumentations/?tab=readme-ov-file
Migration 004 will fail if "Personal" workspaces are duplicated.
This migration creates a unique partial index on owner_id where name='Personal'. The workspace_service.py creates "Personal" workspaces but lacks atomic constraints (e.g., get_or_create) or data cleanup logic to prevent duplicates. If multiple "Personal" rows exist for a single owner_id (e.g., from repeated bootstrap calls or race conditions), the CREATE UNIQUE INDEX operation will fail, blocking the upgrade.
Add a data cleanup step to delete older duplicates for each owner_id before creating the index.
Relevant code context
apps/web-backend/migrations/versions/004_unique_personal_workspace.py
def upgrade() -> None:
"""Add a partial unique index: at most one 'Personal' workspace per owner."""
op.create_index(
_INDEX_NAME,
"workspaces",
["owner_id"],
unique=True,
sqlite_where=sa.text("name = 'Personal'"),
postgresql_where=sa.text("name = 'Personal'"),
)apps/web-backend/services/workspace_service.py
PERSONAL_WORKSPACE_NAME = "Personal"
# ...
workspace = Workspace(name=PERSONAL_WORKSPACE_NAME, owner_id=user_id)🤖 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/migrations/versions/004_unique_personal_workspace.py` around
lines 33 - 42, The `upgrade()` migration in `004_unique_personal_workspace.py`
can fail when duplicate `Personal` workspaces already exist for the same
`owner_id`. Add a cleanup step before `op.create_index` that identifies
duplicate `workspaces` rows for `name = 'Personal'`, keeps one row per
`owner_id`, and deletes the older extras so the unique partial index can be
created successfully. Use the `PERSONAL_WORKSPACE_NAME` and `Workspace` creation
path in `workspace_service.py` as the context for the affected records.



Second slice of Track C (cloud multitenancy — see docs/strategy/roadmap.md), building on the merged C1 models/permissions. C2 is the workspace-context layer that makes those models usable.
What's delivered
CLOUD_MODEconfig flag (single|team, validated; defaultsingle).services/workspace_service.py:get_or_create_personal_workspace(idempotent single-mode default),list_accessible_workspaces(owned ∪ member),create_workspace.get_current_workspacedependency (core/permissions.py): single mode → the caller's auto-created Personal workspace; team mode →?workspace_idwith a>= vieweraccess check (400 if missing, 403 if denied). Extracted a shared_current_user_idhelper (now reused byrequire_workspace_access).api/routes/workspaces.py, registered inmain.py):GET /api/workspaces(list with role),GET /api/workspaces/current,POST /api/workspaces— the data the frontend workspace-switcher needs./register+/login(idempotent; backfills pre-C2 accounts).Tests
11 workspace tests (bootstrap idempotency, owned∪member listing, single/team resolution incl. 400/403, plus an end-to-end HTTP test of the router). Existing auth/user route tests (49) unaffected;
ruffclean; full app imports cleanly and the routes resolve in the OpenAPI schema. Also restores the owner-access test's membership-absence assertion that the C1 squash-merge dropped.Scope note
No schema change. Per-resource scoping (specs/runs/repos by
workspace_id) lands as each resource gains DB persistence — today onlyrepositoriesis a DB model and it has no CRUD routes yet, so resource scoping is deferred to C3 (runs/AgentExecution) and C6 (OAuth repos), per the roadmap.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes