feat(backend): add workspace CRUD API endpoints (PR5)

Workspace management API at /api/orgs/{org_id}/workspaces:

- POST — create workspace (creator becomes admin, supports OPEN/PRIVATE)
- GET — list workspaces (OPEN + user's PRIVATE)
- GET /{ws_id} — workspace details
- PATCH /{ws_id} — update name/description/joinPolicy
- DELETE /{ws_id} — delete (not default workspace)
- POST /{ws_id}/join — self-join OPEN workspaces
- POST /{ws_id}/leave — leave (not default workspace)

Member management:
- GET /{ws_id}/members — list members
- POST /{ws_id}/members — add org member to workspace
- PATCH /{ws_id}/members/{uid} — update role flags
- DELETE /{ws_id}/members/{uid} — remove from workspace

All endpoints use RequestContext permission model. Workspace actions
require workspace-level permissions; creation requires org-level
CREATE_WORKSPACES permission.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-04-01 08:49:47 +02:00
parent 71cb6ccd7e
commit 5e3f536b1c
4 changed files with 512 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
"""Database operations for workspace management."""
import logging
from backend.data.db import prisma
from backend.util.exceptions import NotFoundError
logger = logging.getLogger(__name__)
def _ws_to_dict(ws, member_count: int = 0) -> dict:
return {
"id": ws.id,
"name": ws.name,
"slug": ws.slug,
"description": ws.description,
"isDefault": ws.isDefault,
"joinPolicy": ws.joinPolicy,
"orgId": ws.orgId,
"memberCount": member_count,
"createdAt": ws.createdAt,
}
async def create_workspace(
org_id: str,
name: str,
user_id: str,
description: str | None = None,
join_policy: str = "OPEN",
) -> dict:
"""Create a workspace and make the creator an admin."""
ws = await prisma.orgworkspace.create(
data={
"name": name,
"orgId": org_id,
"description": description,
"joinPolicy": join_policy,
"createdByUserId": user_id,
}
)
# Creator becomes admin
await prisma.orgworkspacemember.create(
data={
"workspaceId": ws.id,
"userId": user_id,
"isAdmin": True,
"status": "ACTIVE",
}
)
return _ws_to_dict(ws, member_count=1)
async def list_workspaces(org_id: str, user_id: str) -> list[dict]:
"""List workspaces: all OPEN workspaces + PRIVATE ones the user belongs to."""
workspaces = await prisma.orgworkspace.find_many(
where={
"orgId": org_id,
"archivedAt": None,
"OR": [
{"joinPolicy": "OPEN"},
{"Members": {"some": {"userId": user_id, "status": "ACTIVE"}}},
],
},
order={"createdAt": "asc"},
)
return [_ws_to_dict(ws) for ws in workspaces]
async def get_workspace(ws_id: str) -> dict:
"""Get workspace details."""
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
return _ws_to_dict(ws)
async def update_workspace(ws_id: str, data: dict) -> dict:
"""Update workspace fields."""
update_data = {k: v for k, v in data.items() if v is not None}
if not update_data:
return await get_workspace(ws_id)
await prisma.orgworkspace.update(where={"id": ws_id}, data=update_data)
return await get_workspace(ws_id)
async def delete_workspace(ws_id: str) -> None:
"""Delete a workspace. Cannot delete the default workspace."""
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.isDefault:
raise ValueError("Cannot delete the default workspace")
await prisma.orgworkspace.delete(where={"id": ws_id})
async def join_workspace(ws_id: str, user_id: str, org_id: str) -> dict:
"""Self-join an OPEN workspace. User must be an org member."""
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.orgId != org_id:
raise ValueError("Workspace does not belong to this organization")
if ws.joinPolicy != "OPEN":
raise ValueError("Cannot self-join a PRIVATE workspace. Request an invite.")
# Check not already a member
existing = await prisma.orgworkspacemember.find_unique(
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
)
if existing:
return _ws_to_dict(ws)
await prisma.orgworkspacemember.create(
data={
"workspaceId": ws_id,
"userId": user_id,
"status": "ACTIVE",
}
)
return _ws_to_dict(ws)
async def leave_workspace(ws_id: str, user_id: str) -> None:
"""Leave a workspace. Cannot leave the default workspace."""
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.isDefault:
raise ValueError("Cannot leave the default workspace")
await prisma.orgworkspacemember.delete_many(
where={"workspaceId": ws_id, "userId": user_id}
)
async def list_workspace_members(ws_id: str) -> list[dict]:
"""List all active members of a workspace."""
members = await prisma.orgworkspacemember.find_many(
where={"workspaceId": ws_id, "status": "ACTIVE"},
include={"User": True},
)
return [
{
"id": m.id,
"userId": m.userId,
"email": m.User.email if m.User else "",
"name": m.User.name if m.User else None,
"isAdmin": m.isAdmin,
"isBillingManager": m.isBillingManager,
"joinedAt": m.joinedAt,
}
for m in members
]
async def add_workspace_member(
ws_id: str,
user_id: str,
org_id: str,
is_admin: bool = False,
is_billing_manager: bool = False,
invited_by: str | None = None,
) -> dict:
"""Add a member to a workspace. Must be an org member."""
# Verify user is in the org
org_member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
if org_member is None:
raise ValueError(f"User {user_id} is not a member of the organization")
member = await prisma.orgworkspacemember.create(
data={
"workspaceId": ws_id,
"userId": user_id,
"isAdmin": is_admin,
"isBillingManager": is_billing_manager,
"status": "ACTIVE",
"invitedByUserId": invited_by,
},
include={"User": True},
)
return {
"id": member.id,
"userId": member.userId,
"email": member.User.email if member.User else "",
"name": member.User.name if member.User else None,
"isAdmin": member.isAdmin,
"isBillingManager": member.isBillingManager,
"joinedAt": member.joinedAt,
}
async def update_workspace_member(
ws_id: str,
user_id: str,
is_admin: bool | None,
is_billing_manager: bool | None,
) -> dict:
"""Update a workspace member's role flags."""
update_data: dict = {}
if is_admin is not None:
update_data["isAdmin"] = is_admin
if is_billing_manager is not None:
update_data["isBillingManager"] = is_billing_manager
if update_data:
await prisma.orgworkspacemember.update(
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}},
data=update_data,
)
members = await list_workspace_members(ws_id)
return next(m for m in members if m["userId"] == user_id)
async def remove_workspace_member(ws_id: str, user_id: str) -> None:
"""Remove a member from a workspace."""
await prisma.orgworkspacemember.delete(
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
)

View File

@@ -0,0 +1,50 @@
"""Pydantic request/response models for workspace management."""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateWorkspaceRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = None
joinPolicy: str = "OPEN" # OPEN or PRIVATE
class UpdateWorkspaceRequest(BaseModel):
name: str | None = None
description: str | None = None
joinPolicy: str | None = None # OPEN or PRIVATE
class WorkspaceResponse(BaseModel):
id: str
name: str
slug: str | None
description: str | None
isDefault: bool
joinPolicy: str
orgId: str
memberCount: int
createdAt: datetime
class WorkspaceMemberResponse(BaseModel):
id: str
userId: str
email: str
name: str | None
isAdmin: bool
isBillingManager: bool
joinedAt: datetime
class AddWorkspaceMemberRequest(BaseModel):
userId: str
isAdmin: bool = False
isBillingManager: bool = False
class UpdateWorkspaceMemberRequest(BaseModel):
isAdmin: bool | None = None
isBillingManager: bool | None = None

View File

@@ -0,0 +1,230 @@
"""Workspace management API routes (nested under /api/orgs/{org_id}/workspaces)."""
from typing import Annotated
from autogpt_libs.auth import (
get_request_context,
requires_org_permission,
requires_workspace_permission,
)
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction, WorkspaceAction
from fastapi import APIRouter, HTTPException, Security
from . import workspace_db as ws_db
from .workspace_model import (
AddWorkspaceMemberRequest,
CreateWorkspaceRequest,
UpdateWorkspaceMemberRequest,
UpdateWorkspaceRequest,
WorkspaceMemberResponse,
WorkspaceResponse,
)
router = APIRouter()
@router.post(
"",
summary="Create workspace",
tags=["orgs", "workspaces"],
)
async def create_workspace(
org_id: str,
request: CreateWorkspaceRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.CREATE_WORKSPACES)),
],
) -> WorkspaceResponse:
result = await ws_db.create_workspace(
org_id=org_id,
name=request.name,
user_id=ctx.user_id,
description=request.description,
join_policy=request.joinPolicy,
)
return WorkspaceResponse(**result)
@router.get(
"",
summary="List workspaces",
tags=["orgs", "workspaces"],
)
async def list_workspaces(
org_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[WorkspaceResponse]:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
results = await ws_db.list_workspaces(org_id, ctx.user_id)
return [WorkspaceResponse(**r) for r in results]
@router.get(
"/{ws_id}",
summary="Get workspace details",
tags=["orgs", "workspaces"],
)
async def get_workspace(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> WorkspaceResponse:
result = await ws_db.get_workspace(ws_id)
return WorkspaceResponse(**result)
@router.patch(
"/{ws_id}",
summary="Update workspace",
tags=["orgs", "workspaces"],
)
async def update_workspace(
org_id: str,
ws_id: str,
request: UpdateWorkspaceRequest,
ctx: Annotated[
RequestContext,
Security(requires_workspace_permission(WorkspaceAction.MANAGE_SETTINGS)),
],
) -> WorkspaceResponse:
result = await ws_db.update_workspace(
ws_id,
{
"name": request.name,
"description": request.description,
"joinPolicy": request.joinPolicy,
},
)
return WorkspaceResponse(**result)
@router.delete(
"/{ws_id}",
summary="Delete workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def delete_workspace(
org_id: str,
ws_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_WORKSPACES)),
],
) -> None:
await ws_db.delete_workspace(ws_id)
@router.post(
"/{ws_id}/join",
summary="Self-join open workspace",
tags=["orgs", "workspaces"],
)
async def join_workspace(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> WorkspaceResponse:
result = await ws_db.join_workspace(ws_id, ctx.user_id, org_id)
return WorkspaceResponse(**result)
@router.post(
"/{ws_id}/leave",
summary="Leave workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def leave_workspace(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> None:
await ws_db.leave_workspace(ws_id, ctx.user_id)
# --- Members ---
@router.get(
"/{ws_id}/members",
summary="List workspace members",
tags=["orgs", "workspaces"],
)
async def list_members(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[WorkspaceMemberResponse]:
results = await ws_db.list_workspace_members(ws_id)
return [WorkspaceMemberResponse(**r) for r in results]
@router.post(
"/{ws_id}/members",
summary="Add member to workspace",
tags=["orgs", "workspaces"],
)
async def add_member(
org_id: str,
ws_id: str,
request: AddWorkspaceMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
],
) -> WorkspaceMemberResponse:
result = await ws_db.add_workspace_member(
ws_id=ws_id,
user_id=request.userId,
org_id=org_id,
is_admin=request.isAdmin,
is_billing_manager=request.isBillingManager,
invited_by=ctx.user_id,
)
return WorkspaceMemberResponse(**result)
@router.patch(
"/{ws_id}/members/{uid}",
summary="Update workspace member role",
tags=["orgs", "workspaces"],
)
async def update_member(
org_id: str,
ws_id: str,
uid: str,
request: UpdateWorkspaceMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
],
) -> WorkspaceMemberResponse:
result = await ws_db.update_workspace_member(
ws_id=ws_id,
user_id=uid,
is_admin=request.isAdmin,
is_billing_manager=request.isBillingManager,
)
return WorkspaceMemberResponse(**result)
@router.delete(
"/{ws_id}/members/{uid}",
summary="Remove member from workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def remove_member(
org_id: str,
ws_id: str,
uid: str,
ctx: Annotated[
RequestContext,
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
],
) -> None:
await ws_db.remove_workspace_member(ws_id, uid)

View File

@@ -30,6 +30,7 @@ import backend.api.features.library.routes
import backend.api.features.mcp.routes as mcp_routes
import backend.api.features.oauth
import backend.api.features.orgs.routes as org_routes
import backend.api.features.orgs.workspace_routes
import backend.api.features.otto.routes
import backend.api.features.postmark.postmark
import backend.api.features.store.model
@@ -369,6 +370,11 @@ app.include_router(
tags=["v2", "orgs"],
prefix="/api/orgs",
)
app.include_router(
backend.api.features.orgs.workspace_routes.router,
tags=["v2", "orgs", "workspaces"],
prefix="/api/orgs/{org_id}/workspaces",
)
app.mount("/external-api", external_api)