mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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}}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user