From d55f4153658624d9593c22abcf16ed8ffd692e5e Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Thu, 11 Jun 2026 18:59:50 +0000 Subject: [PATCH 01/12] feat: updating workspace addition --- app/api/v1/api.py | 2 + app/api/v1/routes/agents.py | 10 + app/api/v1/routes/call_import_evaluations.py | 11 + app/api/v1/routes/call_import_schemas.py | 10 + app/api/v1/routes/call_imports.py | 11 + app/api/v1/routes/evaluations.py | 11 + app/api/v1/routes/evaluator_results.py | 11 + app/api/v1/routes/evaluators.py | 10 + app/api/v1/routes/judge_alignment.py | 11 + app/api/v1/routes/metrics.py | 10 + app/api/v1/routes/observability.py | 11 + app/api/v1/routes/personas.py | 10 + app/api/v1/routes/playground.py | 10 + app/api/v1/routes/prompt_optimization.py | 10 + app/api/v1/routes/prompt_partials.py | 10 + app/api/v1/routes/results.py | 10 + app/api/v1/routes/scenarios.py | 10 + app/api/v1/routes/settings.py | 33 +- app/api/v1/routes/test_agents.py | 10 + app/api/v1/routes/voice_playground.py | 11 + app/api/v1/routes/workspace_iam.py | 402 +++++++++++++++++ app/api/v1/routes/workspaces.py | 161 ++++--- app/core/auth/capabilities.py | 137 ++++++ app/core/auth/rbac.py | 2 +- app/core/auth/workspace_route_capabilities.py | 57 +++ app/dependencies.py | 222 ++++++--- app/migrations/048_workspace_rbac.py | 282 ++++++++++++ app/models/database.py | 96 +++- app/models/schemas.py | 66 +++ app/services/organization_provisioning.py | 14 + app/services/workspace_rbac.py | 239 ++++++++++ .../specs/2026-06-11-workspace-rbac-design.md | 58 +++ frontend/src/App.tsx | 2 + .../src/components/CreateWorkspaceModal.tsx | 426 ++++++++++++++++++ frontend/src/components/Layout.tsx | 1 + .../src/components/WorkspaceRolesSection.tsx | 216 +++++++++ frontend/src/components/WorkspaceSwitcher.tsx | 325 ++++++------- .../src/hooks/useWorkspaceCapabilities.ts | 20 + frontend/src/lib/api.ts | 73 +++ frontend/src/pages/iam/IAM.tsx | 7 + .../src/pages/workspace/WorkspaceMembers.tsx | 225 +++++++++ frontend/src/store/workspaceStore.ts | 33 +- frontend/src/types/api.ts | 49 ++ tests/conftest.py | 78 +++- tests/test_api/conftest.py | 21 + tests/test_api/test_auth_routes.py | 88 ++-- tests/test_api/test_workspace_iam.py | 216 +++++++++ tests/test_api/test_workspaces.py | 5 +- 48 files changed, 3331 insertions(+), 412 deletions(-) create mode 100644 app/api/v1/routes/workspace_iam.py create mode 100644 app/core/auth/capabilities.py create mode 100644 app/core/auth/workspace_route_capabilities.py create mode 100644 app/migrations/048_workspace_rbac.py create mode 100644 app/services/workspace_rbac.py create mode 100644 docs/superpowers/specs/2026-06-11-workspace-rbac-design.md create mode 100644 frontend/src/components/CreateWorkspaceModal.tsx create mode 100644 frontend/src/components/WorkspaceRolesSection.tsx create mode 100644 frontend/src/hooks/useWorkspaceCapabilities.ts create mode 100644 frontend/src/pages/workspace/WorkspaceMembers.tsx create mode 100644 tests/test_api/test_workspace_iam.py diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 90fb2602..804ba6ce 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -40,6 +40,7 @@ call_import_evaluations, judge_alignment, workspaces, + workspace_iam, ) api_router = APIRouter() @@ -83,3 +84,4 @@ api_router.include_router(call_import_evaluations.router) api_router.include_router(judge_alignment.router) api_router.include_router(workspaces.router) +api_router.include_router(workspace_iam.router) diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py index 9bcefbbc..812b4f93 100644 --- a/app/api/v1/routes/agents.py +++ b/app/api/v1/routes/agents.py @@ -652,3 +652,13 @@ async def get_agent_delete_impact( "can_delete_without_force": len(dependencies) == 0, } + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/call_import_evaluations.py b/app/api/v1/routes/call_import_evaluations.py index ce91d644..c74222a9 100644 --- a/app/api/v1/routes/call_import_evaluations.py +++ b/app/api/v1/routes/call_import_evaluations.py @@ -8583,3 +8583,14 @@ async def retry_call_import_evaluation_row( created_at=eval_row.created_at, updated_at=eval_row.updated_at, ) + + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) diff --git a/app/api/v1/routes/call_import_schemas.py b/app/api/v1/routes/call_import_schemas.py index 4f4fba7b..86b414b8 100644 --- a/app/api/v1/routes/call_import_schemas.py +++ b/app/api/v1/routes/call_import_schemas.py @@ -396,3 +396,13 @@ async def delete_call_import_schema( db.delete(schema) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) + + +from app.core.auth.capabilities import CALLS_IMPORT, CALLS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=CALLS_VIEW, + manage_capability=CALLS_IMPORT, +) diff --git a/app/api/v1/routes/call_imports.py b/app/api/v1/routes/call_imports.py index 947e7538..8be373a8 100644 --- a/app/api/v1/routes/call_imports.py +++ b/app/api/v1/routes/call_imports.py @@ -3746,3 +3746,14 @@ async def get_call_import_insights( evaluation_count=len(evaluations), metrics=metrics_payload, ) + + +from app.core.auth.capabilities import CALLS_DELETE, CALLS_IMPORT, CALLS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=CALLS_VIEW, + manage_capability=CALLS_IMPORT, + delete_capability=CALLS_DELETE, +) diff --git a/app/api/v1/routes/evaluations.py b/app/api/v1/routes/evaluations.py index a45ee937..840d3803 100644 --- a/app/api/v1/routes/evaluations.py +++ b/app/api/v1/routes/evaluations.py @@ -172,3 +172,14 @@ def delete_evaluation( return {"message": "Evaluation deleted successfully"} + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) + diff --git a/app/api/v1/routes/evaluator_results.py b/app/api/v1/routes/evaluator_results.py index 1c69cd64..c61c8480 100644 --- a/app/api/v1/routes/evaluator_results.py +++ b/app/api/v1/routes/evaluator_results.py @@ -745,3 +745,14 @@ def re_evaluate_result( db.refresh(result) return result + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) + diff --git a/app/api/v1/routes/evaluators.py b/app/api/v1/routes/evaluators.py index 2dfa0b11..1f748e4a 100644 --- a/app/api/v1/routes/evaluators.py +++ b/app/api/v1/routes/evaluators.py @@ -651,3 +651,13 @@ def run_evaluators( evaluator_results=evaluator_results ) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/judge_alignment.py b/app/api/v1/routes/judge_alignment.py index 55f32d09..9fab1ee6 100644 --- a/app/api/v1/routes/judge_alignment.py +++ b/app/api/v1/routes/judge_alignment.py @@ -914,3 +914,14 @@ def recompute_metrics( db.commit() db.refresh(run) return run + + +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_RUN, + run_capability=EVALS_RUN, +) diff --git a/app/api/v1/routes/metrics.py b/app/api/v1/routes/metrics.py index f4bcc2c3..7d43248c 100644 --- a/app/api/v1/routes/metrics.py +++ b/app/api/v1/routes/metrics.py @@ -2197,3 +2197,13 @@ def parse_bulk_metric( return MetricParseBulkResponse(metrics=drafts, parent=parent_payload) + +from app.core.auth.capabilities import METRICS_MANAGE, METRICS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=METRICS_VIEW, + manage_capability=METRICS_MANAGE, +) + diff --git a/app/api/v1/routes/observability.py b/app/api/v1/routes/observability.py index 52c60dd9..61770404 100644 --- a/app/api/v1/routes/observability.py +++ b/app/api/v1/routes/observability.py @@ -582,3 +582,14 @@ async def evaluate_call( "message": "Evaluation queued successfully", } + +from app.core.auth.capabilities import REPORTS_GENERATE, REPORTS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=REPORTS_VIEW, + manage_capability=REPORTS_GENERATE, + run_capability=REPORTS_GENERATE, +) + diff --git a/app/api/v1/routes/personas.py b/app/api/v1/routes/personas.py index 379d8470..1472b3c6 100644 --- a/app/api/v1/routes/personas.py +++ b/app/api/v1/routes/personas.py @@ -841,3 +841,13 @@ async def seed_demo_data( detail=f"Unexpected error seeding demo data: {str(e)}" ) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/playground.py b/app/api/v1/routes/playground.py index 421c8aeb..02651ab1 100644 --- a/app/api/v1/routes/playground.py +++ b/app/api/v1/routes/playground.py @@ -1978,3 +1978,13 @@ async def summarize_transcript( "usage": result.get("usage", {}), } + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/prompt_optimization.py b/app/api/v1/routes/prompt_optimization.py index 19033e40..f0805797 100644 --- a/app/api/v1/routes/prompt_optimization.py +++ b/app/api/v1/routes/prompt_optimization.py @@ -370,3 +370,13 @@ def _get_candidate( raise HTTPException(404, "Candidate not found") return candidate + + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) diff --git a/app/api/v1/routes/prompt_partials.py b/app/api/v1/routes/prompt_partials.py index 138f8e95..e0372c0a 100644 --- a/app/api/v1/routes/prompt_partials.py +++ b/app/api/v1/routes/prompt_partials.py @@ -838,3 +838,13 @@ async def map_prompt_partial_flowchart_nodes( ) db.refresh(partial) return _agent_flowchart_response(partial) + + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) diff --git a/app/api/v1/routes/results.py b/app/api/v1/routes/results.py index 345858bb..1b58d44a 100644 --- a/app/api/v1/routes/results.py +++ b/app/api/v1/routes/results.py @@ -200,3 +200,13 @@ def compare_evaluations( comparison_metrics=comparison_metrics, ) + +from app.core.auth.capabilities import EVALS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=EVALS_VIEW, + manage_capability=EVALS_VIEW, +) + diff --git a/app/api/v1/routes/scenarios.py b/app/api/v1/routes/scenarios.py index 428b7a97..80cd9080 100644 --- a/app/api/v1/routes/scenarios.py +++ b/app/api/v1/routes/scenarios.py @@ -206,3 +206,13 @@ async def delete_scenario( return Response(status_code=204) + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/settings.py b/app/api/v1/routes/settings.py index 095e67ff..a9a5900d 100644 --- a/app/api/v1/routes/settings.py +++ b/app/api/v1/routes/settings.py @@ -14,7 +14,14 @@ import secrets from pydantic import BaseModel -from app.dependencies import get_db, get_api_key, get_organization_id, get_workspace_id +from app.dependencies import ( + get_db, + get_api_key, + get_organization_id, + get_workspace_id, + require_capability, +) +from app.core.auth.capabilities import REPORTS_VIEW, WORKSPACE_SETTINGS from app.models.database import APIKey, User, Organization, Workspace from app.models.schemas import MessageResponse from app.api.v1.routes.profile import get_current_user @@ -130,7 +137,11 @@ def _report_branding_response(workspace: Workspace) -> ReportBrandingResponse: ) -@router.get("/report-branding", response_model=ReportBrandingResponse) +@router.get( + "/report-branding", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(REPORTS_VIEW))], +) def get_report_branding( api_key: str = Depends(get_api_key), organization_id: UUID = Depends(get_organization_id), @@ -151,7 +162,11 @@ def get_report_branding( return _report_branding_response(workspace) -@router.patch("/report-branding", response_model=ReportBrandingResponse) +@router.patch( + "/report-branding", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) def update_report_branding( payload: ReportBrandingUpdateRequest, api_key: str = Depends(get_api_key), @@ -181,7 +196,11 @@ def update_report_branding( return _report_branding_response(workspace) -@router.post("/report-branding/images", response_model=ReportBrandingResponse) +@router.post( + "/report-branding/images", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) async def upload_report_branding_images( files: List[UploadFile] = File(...), role: Literal["internal", "external", "generic"] = Form("generic"), @@ -265,7 +284,11 @@ async def upload_report_branding_images( return _report_branding_response(workspace) -@router.delete("/report-branding/images/{image_id}", response_model=ReportBrandingResponse) +@router.delete( + "/report-branding/images/{image_id}", + response_model=ReportBrandingResponse, + dependencies=[Depends(require_capability(WORKSPACE_SETTINGS))], +) def delete_report_branding_image( image_id: str, api_key: str = Depends(get_api_key), diff --git a/app/api/v1/routes/test_agents.py b/app/api/v1/routes/test_agents.py index 28e1420c..0c402156 100644 --- a/app/api/v1/routes/test_agents.py +++ b/app/api/v1/routes/test_agents.py @@ -274,3 +274,13 @@ async def delete_conversation( db.commit() return None + +from app.core.auth.capabilities import SIM_MANAGE, SIM_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=SIM_VIEW, + manage_capability=SIM_MANAGE, +) + diff --git a/app/api/v1/routes/voice_playground.py b/app/api/v1/routes/voice_playground.py index 57ec9ba6..48cc0f81 100644 --- a/app/api/v1/routes/voice_playground.py +++ b/app/api/v1/routes/voice_playground.py @@ -2470,3 +2470,14 @@ def _recompute_summary(comparison: TTSComparison, db: Session): comparison.evaluation_summary = summary db.commit() + + +from app.core.auth.capabilities import REPORTS_GENERATE, REPORTS_VIEW +from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities + +apply_workspace_route_capabilities( + router, + view_capability=REPORTS_VIEW, + manage_capability=REPORTS_GENERATE, + run_capability=REPORTS_GENERATE, +) diff --git a/app/api/v1/routes/workspace_iam.py b/app/api/v1/routes/workspace_iam.py new file mode 100644 index 00000000..553829bb --- /dev/null +++ b/app/api/v1/routes/workspace_iam.py @@ -0,0 +1,402 @@ +"""Workspace IAM: membership and role management.""" + +from __future__ import annotations + +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import ( + WORKSPACE_MEMBERS_MANAGE, + WORKSPACE_MEMBERS_VIEW, + capabilities_for_registry, + normalize_capabilities, +) +from app.core.auth.rbac import require_admin +from app.database import get_db +from app.dependencies import get_organization_id +from app.models.database import OrganizationMember, User, Workspace, WorkspaceMember, WorkspaceRole +from app.models.schemas import ( + CapabilityDomainResponse, + WorkspaceMemberCreate, + WorkspaceMemberResponse, + WorkspaceMemberUpdate, + WorkspaceRoleCreate, + WorkspaceRoleResponse, + WorkspaceRoleUpdate, +) +from app.services.workspace_rbac import ( + add_workspace_member, + count_workspace_admins, + is_workspace_admin_role, + resolve_workspace_capabilities, + seed_system_workspace_roles, +) + + +router = APIRouter(tags=["Workspace IAM"]) + + +def _require_workspace_capability( + db: Session, + *, + principal: Principal, + organization_id: UUID, + workspace_id: UUID, + capability: str, +) -> None: + workspace = ( + db.query(Workspace) + .filter( + Workspace.id == workspace_id, + Workspace.organization_id == organization_id, + ) + .first() + ) + if workspace is None: + raise HTTPException(status_code=404, detail="Workspace not found.") + caps, _, _ = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace_id, + organization_id=organization_id, + ) + if capability not in caps: + raise HTTPException( + status_code=403, + detail=f"This action requires the '{capability}' capability in this workspace.", + ) + + +def _member_response(db: Session, member: WorkspaceMember) -> WorkspaceMemberResponse: + user = db.query(User).filter(User.id == member.user_id).first() + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + return WorkspaceMemberResponse( + id=member.id, + workspace_id=member.workspace_id, + user_id=member.user_id, + role_id=member.role_id, + role_name=role.name if role else "Unknown", + user_email=user.email if user else "", + user_name=user.name if user else None, + added_by_user_id=member.added_by_user_id, + created_at=member.created_at, + ) + + +@router.get("/capabilities", response_model=List[CapabilityDomainResponse]) +def list_capabilities(): + """Return the capability registry for the role-builder UI.""" + return capabilities_for_registry() + + +@router.get("/workspace-roles", response_model=List[WorkspaceRoleResponse]) +def list_workspace_roles( + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + roles = ( + db.query(WorkspaceRole) + .filter(WorkspaceRole.organization_id == organization_id) + .order_by(WorkspaceRole.is_system.desc(), WorkspaceRole.name.asc()) + .all() + ) + return roles + + +@router.post( + "/workspace-roles", + response_model=WorkspaceRoleResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_admin)], +) +def create_workspace_role( + payload: WorkspaceRoleCreate, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + caps = sorted(normalize_capabilities(payload.capabilities)) + if not caps: + raise HTTPException(status_code=400, detail="At least one valid capability is required.") + + existing = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == payload.name.strip(), + ) + .first() + ) + if existing: + raise HTTPException(status_code=409, detail="A role with this name already exists.") + + role = WorkspaceRole( + organization_id=organization_id, + name=payload.name.strip(), + description=payload.description, + capabilities=caps, + is_system=False, + ) + db.add(role) + db.commit() + db.refresh(role) + return role + + +@router.patch( + "/workspace-roles/{role_id}", + response_model=WorkspaceRoleResponse, + dependencies=[Depends(require_admin)], +) +def update_workspace_role( + role_id: UUID, + payload: WorkspaceRoleUpdate, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + if role.is_system: + raise HTTPException(status_code=400, detail="System roles cannot be modified.") + + if payload.name is not None: + role.name = payload.name.strip() + if payload.description is not None: + role.description = payload.description + if payload.capabilities is not None: + caps = sorted(normalize_capabilities(payload.capabilities)) + if not caps: + raise HTTPException(status_code=400, detail="At least one valid capability is required.") + role.capabilities = caps + + db.commit() + db.refresh(role) + return role + + +@router.delete( + "/workspace-roles/{role_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)], +) +def delete_workspace_role( + role_id: UUID, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + if role.is_system: + raise HTTPException(status_code=400, detail="System roles cannot be deleted.") + + assigned = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.role_id == role_id) + .count() + ) + if assigned: + raise HTTPException( + status_code=409, + detail="Role is assigned to workspace members and cannot be deleted.", + ) + + db.delete(role) + db.commit() + return None + + +@router.get("/workspaces/{workspace_id}/members", response_model=List[WorkspaceMemberResponse]) +def list_workspace_members( + workspace_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_VIEW, + ) + members = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.workspace_id == workspace_id) + .order_by(WorkspaceMember.created_at.asc()) + .all() + ) + return [_member_response(db, member) for member in members] + + +@router.post( + "/workspaces/{workspace_id}/members", + response_model=WorkspaceMemberResponse, + status_code=status.HTTP_201_CREATED, +) +def add_workspace_member_route( + workspace_id: UUID, + payload: WorkspaceMemberCreate, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + org_member = ( + db.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == organization_id, + OrganizationMember.user_id == payload.user_id, + ) + .first() + ) + if org_member is None: + raise HTTPException(status_code=400, detail="User is not a member of this organization.") + + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == payload.role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if role is None: + raise HTTPException(status_code=404, detail="Role not found.") + + member = add_workspace_member( + db, + workspace_id=workspace_id, + user_id=payload.user_id, + role_id=payload.role_id, + added_by_user_id=principal.user_id, + ) + db.commit() + db.refresh(member) + return _member_response(db, member) + + +@router.patch( + "/workspaces/{workspace_id}/members/{user_id}", + response_model=WorkspaceMemberResponse, +) +def update_workspace_member_route( + workspace_id: UUID, + user_id: UUID, + payload: WorkspaceMemberUpdate, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if member is None: + raise HTTPException(status_code=404, detail="Workspace member not found.") + + new_role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.id == payload.role_id, + WorkspaceRole.organization_id == organization_id, + ) + .first() + ) + if new_role is None: + raise HTTPException(status_code=404, detail="Role not found.") + + old_role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + if is_workspace_admin_role(old_role) and not is_workspace_admin_role(new_role): + if count_workspace_admins(db, workspace_id=workspace_id) <= 1: + raise HTTPException( + status_code=409, + detail="Cannot demote the last Workspace Admin of this workspace.", + ) + + member.role_id = payload.role_id + db.commit() + db.refresh(member) + return _member_response(db, member) + + +@router.delete( + "/workspaces/{workspace_id}/members/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def remove_workspace_member_route( + workspace_id: UUID, + user_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + is_self = principal.user_id == user_id + if not is_self: + _require_workspace_capability( + db, + principal=principal, + organization_id=organization_id, + workspace_id=workspace_id, + capability=WORKSPACE_MEMBERS_MANAGE, + ) + + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if member is None: + raise HTTPException(status_code=404, detail="Workspace member not found.") + + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + if is_workspace_admin_role(role) and count_workspace_admins(db, workspace_id=workspace_id) <= 1: + raise HTTPException( + status_code=409, + detail="Cannot remove the last Workspace Admin of this workspace.", + ) + + db.delete(member) + db.commit() + return None diff --git a/app/api/v1/routes/workspaces.py b/app/api/v1/routes/workspaces.py index c86945d7..81b6d076 100644 --- a/app/api/v1/routes/workspaces.py +++ b/app/api/v1/routes/workspaces.py @@ -1,12 +1,4 @@ -"""Workspace management routes. - -Workspaces are the in-org isolation boundary picked up by the -``X-Workspace-Id`` header (see ``app.dependencies.get_workspace_id``). -Members can list, create, rename and delete workspaces within their -own organization; the Default workspace can never be deleted because -the rest of the system uses it as the fallback when a request arrives -without an explicit workspace header. -""" +"""Workspace management routes with capability-based access control.""" from __future__ import annotations @@ -18,63 +10,98 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import WORKSPACE_SETTINGS +from app.core.auth.rbac import get_org_role, require_admin, require_writer from app.database import get_db from app.dependencies import get_organization_id -from app.models.database import Workspace +from app.models.database import RoleEnum, Workspace, WorkspaceMember, WorkspaceRole from app.models.schemas import ( WorkspaceCreate, WorkspaceResponse, WorkspaceUpdate, ) +from app.services.workspace_rbac import ( + ensure_creator_workspace_admin, + resolve_workspace_capabilities, + seed_system_workspace_roles, +) router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) - -# Slugs use underscores so they round-trip cleanly through the UI and URL -# path (no percent-encoded hyphens, no collisions with our default -# ``"default"`` slug). _SLUG_PATTERN = re.compile(r"[^a-z0-9_]+") def _slugify(value: str) -> str: - """Best-effort slug derivation from a display name. - - Lower-cases, replaces runs of non-alphanumerics with underscores, - and trims leading/trailing underscores. Empty inputs collapse to - ``"workspace"`` so we never persist an empty slug. - """ cleaned = _SLUG_PATTERN.sub("_", value.strip().lower()).strip("_") return cleaned or "workspace" +def _workspace_response( + db: Session, + *, + workspace: Workspace, + principal: Principal, +) -> WorkspaceResponse: + caps, _membership, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace.id, + organization_id=workspace.organization_id, + ) + return WorkspaceResponse( + id=workspace.id, + organization_id=workspace.organization_id, + name=workspace.name, + slug=workspace.slug, + is_default=workspace.is_default, + created_at=workspace.created_at, + updated_at=workspace.updated_at, + role_id=role.id if role else None, + role_name=role.name if role else ("Org Admin" if caps else None), + capabilities=sorted(caps), + ) + + @router.get("", response_model=List[WorkspaceResponse]) def list_workspaces( + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """List every workspace in the caller's organization.""" - workspaces = ( - db.query(Workspace) - .filter(Workspace.organization_id == organization_id) - # Default first, then alphabetical so the UI list reads naturally. - .order_by(Workspace.is_default.desc(), Workspace.name.asc()) - .all() - ) - return workspaces + """List workspaces the caller can access.""" + org_role = get_org_role(principal, db) + query = db.query(Workspace).filter(Workspace.organization_id == organization_id) + + if org_role != RoleEnum.ADMIN and principal.user_id is not None: + member_ws_ids = [ + row[0] + for row in db.query(WorkspaceMember.workspace_id) + .filter(WorkspaceMember.user_id == principal.user_id) + .all() + ] + if not member_ws_ids: + return [] + query = query.filter(Workspace.id.in_(member_ws_ids)) + + workspaces = query.order_by(Workspace.is_default.desc(), Workspace.name.asc()).all() + return [_workspace_response(db, workspace=ws, principal=principal) for ws in workspaces] @router.post( "", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_writer)], ) def create_workspace( payload: WorkspaceCreate, + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """Create a new (non-default) workspace in the caller's org.""" + """Create a new (non-default) workspace; creator becomes Workspace Admin.""" slug = (payload.slug or _slugify(payload.name)).strip().lower() if not slug: raise HTTPException( @@ -82,10 +109,6 @@ def create_workspace( detail="Workspace slug cannot be empty.", ) - # Pre-check the slug so we can return a clean 409 even on backends - # whose IntegrityError messages don't include the constraint name - # (e.g. SQLite in tests). We still rely on the unique index for - # the race-condition safety net below. existing = ( db.query(Workspace) .filter( @@ -97,20 +120,26 @@ def create_workspace( if existing is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - f"A workspace with slug '{slug}' already exists in " - "this organization." - ), + detail=f"A workspace with slug '{slug}' already exists in this organization.", ) + seed_system_workspace_roles(db, organization_id=organization_id) + workspace = Workspace( organization_id=organization_id, name=payload.name.strip(), slug=slug, is_default=False, + created_by_user_id=principal.user_id, ) db.add(workspace) try: + db.flush() + ensure_creator_workspace_admin( + db, + workspace=workspace, + user_id=principal.user_id, + ) db.commit() except IntegrityError as exc: db.rollback() @@ -118,27 +147,40 @@ def create_workspace( if "uq_workspaces_org_slug" in message or "unique" in message: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - f"A workspace with slug '{slug}' already exists in " - "this organization." - ), + detail=f"A workspace with slug '{slug}' already exists in this organization.", ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Could not create workspace.", ) db.refresh(workspace) - return workspace + return _workspace_response(db, workspace=workspace, principal=principal) -@router.patch("/{workspace_id}", response_model=WorkspaceResponse) +@router.patch( + "/{workspace_id}", + response_model=WorkspaceResponse, +) def update_workspace( workspace_id: UUID, payload: WorkspaceUpdate, + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): """Rename a workspace (slug stays put to keep deep-links stable).""" + caps, _, _ = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace_id, + organization_id=organization_id, + ) + if WORKSPACE_SETTINGS not in caps: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"This action requires the '{WORKSPACE_SETTINGS}' capability in this workspace.", + ) + workspace = ( db.query(Workspace) .filter( @@ -148,30 +190,25 @@ def update_workspace( .first() ) if workspace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Workspace not found.", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.") workspace.name = payload.name.strip() db.commit() db.refresh(workspace) - return workspace + return _workspace_response(db, workspace=workspace, principal=principal) -@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{workspace_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)], +) def delete_workspace( workspace_id: UUID, organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): - """Delete a non-default workspace. - - The Default workspace is the safety net for headerless requests and - legacy rows, so it can never be deleted. Workspaces that still own - resources will be rejected by the ``ON DELETE RESTRICT`` FKs - declared in migrations 033/034 and surface here as a 409. - """ + """Delete a non-default workspace (org admin only).""" workspace = ( db.query(Workspace) .filter( @@ -181,10 +218,7 @@ def delete_workspace( .first() ) if workspace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Workspace not found.", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.") if workspace.is_default: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -198,9 +232,6 @@ def delete_workspace( db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=( - "Workspace still contains resources. Move or delete " - "them before removing the workspace." - ), + detail="Workspace still contains resources. Move or delete them before removing the workspace.", ) return None diff --git a/app/core/auth/capabilities.py b/app/core/auth/capabilities.py new file mode 100644 index 00000000..df3f1078 --- /dev/null +++ b/app/core/auth/capabilities.py @@ -0,0 +1,137 @@ +""" +Workspace capability registry. + +Capabilities are fixed strings defined in code. Workspace roles (system or +custom) are bundles of these strings stored in the database. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, FrozenSet, List, Set + + +# --------------------------------------------------------------------------- +# Capability constants +# --------------------------------------------------------------------------- + +CALLS_VIEW = "calls.view" +CALLS_IMPORT = "calls.import" +CALLS_DELETE = "calls.delete" + +METRICS_VIEW = "metrics.view" +METRICS_MANAGE = "metrics.manage" + +EVALS_VIEW = "evals.view" +EVALS_RUN = "evals.run" + +SIM_VIEW = "sim.view" +SIM_MANAGE = "sim.manage" + +REPORTS_VIEW = "reports.view" +REPORTS_GENERATE = "reports.generate" + +WORKSPACE_SETTINGS = "workspace.settings" +WORKSPACE_MEMBERS_VIEW = "workspace.members.view" +WORKSPACE_MEMBERS_MANAGE = "workspace.members.manage" + + +ALL_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_VIEW, + CALLS_IMPORT, + CALLS_DELETE, + METRICS_VIEW, + METRICS_MANAGE, + EVALS_VIEW, + EVALS_RUN, + SIM_VIEW, + SIM_MANAGE, + REPORTS_VIEW, + REPORTS_GENERATE, + WORKSPACE_SETTINGS, + WORKSPACE_MEMBERS_VIEW, + WORKSPACE_MEMBERS_MANAGE, + } +) + +VIEW_CAPABILITIES: FrozenSet[str] = frozenset( + cap for cap in ALL_CAPABILITIES if cap.endswith(".view") +) + +EDITOR_EXTRA_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_IMPORT, + METRICS_MANAGE, + EVALS_RUN, + SIM_MANAGE, + REPORTS_GENERATE, + } +) + +ADMIN_EXTRA_CAPABILITIES: FrozenSet[str] = frozenset( + { + CALLS_DELETE, + WORKSPACE_SETTINGS, + WORKSPACE_MEMBERS_MANAGE, + } +) + +VIEWER_ROLE_CAPABILITIES: List[str] = sorted(VIEW_CAPABILITIES) +EDITOR_ROLE_CAPABILITIES: List[str] = sorted( + VIEW_CAPABILITIES | EDITOR_EXTRA_CAPABILITIES +) +WORKSPACE_ADMIN_ROLE_CAPABILITIES: List[str] = sorted(ALL_CAPABILITIES) + +SYSTEM_ROLE_VIEWER = "Viewer" +SYSTEM_ROLE_EDITOR = "Editor" +SYSTEM_ROLE_ADMIN = "Workspace Admin" + + +@dataclass(frozen=True) +class CapabilityDomain: + """Grouping for the role-builder UI.""" + + key: str + label: str + capabilities: tuple[str, ...] + + +CAPABILITY_DOMAINS: tuple[CapabilityDomain, ...] = ( + CapabilityDomain("calls", "Calls", (CALLS_VIEW, CALLS_IMPORT, CALLS_DELETE)), + CapabilityDomain("metrics", "Metrics", (METRICS_VIEW, METRICS_MANAGE)), + CapabilityDomain("evals", "Evaluations", (EVALS_VIEW, EVALS_RUN)), + CapabilityDomain("sim", "Simulation", (SIM_VIEW, SIM_MANAGE)), + CapabilityDomain("reports", "Reports", (REPORTS_VIEW, REPORTS_GENERATE)), + CapabilityDomain( + "workspace", + "Workspace", + (WORKSPACE_SETTINGS, WORKSPACE_MEMBERS_VIEW, WORKSPACE_MEMBERS_MANAGE), + ), +) + + +def normalize_capabilities(raw: List[str] | None) -> Set[str]: + """Return known capabilities from a role's stored list.""" + if not raw: + return set() + return {cap for cap in raw if cap in ALL_CAPABILITIES} + + +def capabilities_for_registry() -> List[Dict[str, object]]: + """Serialize the registry for GET /capabilities.""" + return [ + { + "key": domain.key, + "label": domain.label, + "capabilities": [ + {"key": cap, "label": _capability_label(cap)} for cap in domain.capabilities + ], + } + for domain in CAPABILITY_DOMAINS + ] + + +def _capability_label(cap: str) -> str: + action = cap.split(".")[-1].replace("_", " ") + return action.title() diff --git a/app/core/auth/rbac.py b/app/core/auth/rbac.py index 50b542fc..16f2bfe5 100644 --- a/app/core/auth/rbac.py +++ b/app/core/auth/rbac.py @@ -24,8 +24,8 @@ from fastapi import Depends, HTTPException, status from sqlalchemy.orm import Session -from app.core.auth.principal import Principal from app.core.auth.dependency import get_principal +from app.core.auth.principal import Principal from app.database import get_db from app.models.database import OrganizationMember, RoleEnum diff --git a/app/core/auth/workspace_route_capabilities.py b/app/core/auth/workspace_route_capabilities.py new file mode 100644 index 00000000..2fb1c102 --- /dev/null +++ b/app/core/auth/workspace_route_capabilities.py @@ -0,0 +1,57 @@ +"""Apply default workspace capability dependencies to a FastAPI router.""" + +from __future__ import annotations + +from typing import Iterable, Set + +from fastapi import Depends +from starlette.routing import BaseRoute + +from app.dependencies import require_capability + + +def apply_workspace_route_capabilities( + router, + *, + view_capability: str, + manage_capability: str, + run_capability: str | None = None, + delete_capability: str | None = None, + skip_paths: Iterable[str] | None = None, +) -> None: + """ + Attach capability dependencies to routes on ``router`` based on HTTP method. + + GET/HEAD -> view_capability + POST/PUT/PATCH -> manage_capability (or run_capability when path ends with /run) + DELETE -> delete_capability or manage_capability + """ + skipped = set(skip_paths or ()) + run_paths: Set[str] = set() + + for route in router.routes: + if not isinstance(route, BaseRoute): + continue + path = getattr(route, "path", "") or "" + if path in skipped: + continue + methods = getattr(route, "methods", None) or set() + deps = list(getattr(route, "dependencies", None) or []) + + if methods <= {"GET", "HEAD"} or (methods & {"GET", "HEAD"} and not methods - {"GET", "HEAD"}): + deps.append(Depends(require_capability(view_capability))) + elif "DELETE" in methods: + cap = delete_capability or manage_capability + deps.append(Depends(require_capability(cap))) + elif methods & {"POST", "PUT", "PATCH"}: + cap = manage_capability + if run_capability and _looks_like_run_route(path): + cap = run_capability + deps.append(Depends(require_capability(cap))) + + route.dependencies = deps + + +def _looks_like_run_route(path: str) -> bool: + lowered = path.lower() + return lowered.endswith("/run") or "/run/" in lowered diff --git a/app/dependencies.py b/app/dependencies.py index 7407d72f..72a70c1d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,16 +9,21 @@ which carries user_id, organization_id, and auth_method in one place. """ -from typing import Optional +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Set from uuid import UUID -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header, HTTPException, Request from sqlalchemy.orm import Session from app.core.auth import Principal, get_principal # noqa: F401 - re-exported +from app.core.auth.rbac import get_org_role from app.core.license import is_feature_enabled from app.database import get_db -from app.models.database import Workspace +from app.models.database import RoleEnum, Workspace, WorkspaceMember +from app.services.workspace_rbac import resolve_workspace_capabilities def get_api_key( @@ -53,68 +58,177 @@ def get_organization_id( return principal.organization_id -def get_workspace_id( +@dataclass(frozen=True) +class WorkspaceContext: + """Resolved active workspace plus the caller's capabilities within it.""" + + workspace_id: UUID + organization_id: UUID + capabilities: frozenset[str] + role_id: UUID | None = None + role_name: str | None = None + is_org_admin: bool = False + + +def _resolve_workspace_row( + db: Session, + *, + organization_id: UUID, + workspace_id: UUID | None, + principal: Principal, +) -> Workspace: + org_role = get_org_role(principal, db) + is_org_admin = org_role == RoleEnum.ADMIN + + if workspace_id is not None: + workspace = ( + db.query(Workspace) + .filter( + Workspace.id == workspace_id, + Workspace.organization_id == organization_id, + ) + .first() + ) + if workspace is None: + raise HTTPException( + status_code=404, + detail="Workspace not found in this organization.", + ) + if not is_org_admin and principal.user_id is not None: + member = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace.id, + WorkspaceMember.user_id == principal.user_id, + ) + .first() + ) + if member is None: + raise HTTPException( + status_code=403, + detail="You don't have access to this workspace.", + ) + return workspace + + if is_org_admin or principal.user_id is None: + default_ws = ( + db.query(Workspace) + .filter( + Workspace.organization_id == organization_id, + Workspace.is_default.is_(True), + ) + .first() + ) + if default_ws is None: + raise HTTPException( + status_code=500, + detail=( + "No default workspace exists for this organization. " + "Please contact support; migration 033 may not have run." + ), + ) + return default_ws + + membership_rows = ( + db.query(WorkspaceMember, Workspace) + .join(Workspace, Workspace.id == WorkspaceMember.workspace_id) + .filter( + Workspace.organization_id == organization_id, + WorkspaceMember.user_id == principal.user_id, + ) + .order_by(Workspace.is_default.desc(), Workspace.name.asc()) + .all() + ) + if not membership_rows: + raise HTTPException( + status_code=403, + detail="You don't have access to any workspace in this organization.", + ) + return membership_rows[0][1] + + +def get_workspace_context( + request: Request, x_workspace_id: Optional[str] = Header(None, alias="X-Workspace-Id"), + principal: Principal = Depends(get_principal), organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), -) -> UUID: - """Return the active workspace UUID for the authenticated caller. - - Resolution order: - 1. ``X-Workspace-Id`` header. The referenced workspace must belong - to the caller's organization, otherwise we 404 (don't leak the - existence of another org's workspace). - 2. The organization's Default workspace (``is_default = True``). - This is the back-compat path for existing API-key consumers - that don't know about workspaces yet - migration 033 seeds a - Default for every org so this always resolves. - - A 500 is raised if step 2 fails because the migration didn't run; - callers should never see that in healthy deployments. - """ +) -> WorkspaceContext: + """Resolve workspace + capability set once per request (cached on request.state).""" + cached = getattr(request.state, "workspace_context", None) + if cached is not None: + return cached + parsed_ws_id: UUID | None = None if x_workspace_id: try: - ws_id = UUID(x_workspace_id) + parsed_ws_id = UUID(x_workspace_id) except (ValueError, TypeError): raise HTTPException( status_code=400, detail="X-Workspace-Id must be a valid UUID.", ) - ws_id_row = ( - db.query(Workspace.id) - .filter( - Workspace.id == ws_id, - Workspace.organization_id == organization_id, - ) - .first() - ) - if ws_id_row is None: - raise HTTPException( - status_code=404, detail="Workspace not found in this organization." - ) - return ws_id_row[0] - - default_ws_row = ( - db.query(Workspace.id) - .filter( - Workspace.organization_id == organization_id, - Workspace.is_default.is_(True), - ) - .first() - ) - if default_ws_row is None: - # Should never happen post-migration. Raising 500 is preferable - # to silently writing rows with a NULL workspace_id and tripping - # the NOT NULL constraint downstream. - raise HTTPException( - status_code=500, - detail=( - "No default workspace exists for this organization. " - "Please contact support; migration 033 may not have run." - ), - ) - return default_ws_row[0] + + workspace = _resolve_workspace_row( + db, + organization_id=organization_id, + workspace_id=parsed_ws_id, + principal=principal, + ) + capabilities, membership, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=workspace.id, + organization_id=organization_id, + ) + org_role = get_org_role(principal, db) + ctx = WorkspaceContext( + workspace_id=workspace.id, + organization_id=organization_id, + capabilities=frozenset(capabilities), + role_id=role.id if role else (membership.role_id if membership else None), + role_name=role.name if role else None, + is_org_admin=org_role == RoleEnum.ADMIN, + ) + request.state.workspace_context = ctx + return ctx + + +def get_workspace_id( + ctx: WorkspaceContext = Depends(get_workspace_context), +) -> UUID: + """Return the active workspace UUID for the authenticated caller.""" + return ctx.workspace_id + + +def get_workspace_capabilities( + ctx: WorkspaceContext = Depends(get_workspace_context), +) -> Set[str]: + """Return the caller's capability set for the active workspace.""" + return set(ctx.capabilities) + + +def require_capability(capability: str): + """ + Build a FastAPI dependency that ensures the caller has a workspace capability. + + Requires ``get_workspace_context`` to have run (directly or via + ``get_workspace_id``) so capabilities are resolved for the active workspace. + Org admins and unbound API keys receive all capabilities implicitly. + """ + + def _dep(ctx: WorkspaceContext = Depends(get_workspace_context)) -> WorkspaceContext: + if capability not in ctx.capabilities: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"This action requires the '{capability}' capability " + f"in the active workspace." + ), + ) + return ctx + + return _dep def get_db_session() -> Session: diff --git a/app/migrations/048_workspace_rbac.py b/app/migrations/048_workspace_rbac.py new file mode 100644 index 00000000..fee10322 --- /dev/null +++ b/app/migrations/048_workspace_rbac.py @@ -0,0 +1,282 @@ +""" +Migration: Workspace RBAC (roles, memberships, backfill). + +Adds: + * ``workspace_roles`` table - org-scoped capability bundles (system + custom). + * ``workspace_members`` table - user membership per workspace. + * Seeds Viewer / Editor / Workspace Admin system roles per org. + * Backfills every org member into every org workspace (preserves access on upgrade). + +Idempotent: every step checks for prior state before applying. +""" + +from __future__ import annotations + +import json + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = ( + "Add workspace_roles and workspace_members for capability-based workspace RBAC; " + "seed system roles and backfill memberships." +) + +SYSTEM_ROLES = ( + ( + "Viewer", + "Read-only access to workspace resources.", + sorted( + { + "calls.view", + "metrics.view", + "evals.view", + "sim.view", + "reports.view", + "workspace.members.view", + } + ), + ), + ( + "Editor", + "View and modify workspace resources without admin settings.", + sorted( + { + "calls.view", + "calls.import", + "metrics.view", + "metrics.manage", + "evals.view", + "evals.run", + "sim.view", + "sim.manage", + "reports.view", + "reports.generate", + "workspace.members.view", + } + ), + ), + ( + "Workspace Admin", + "Full access including workspace settings and member management.", + sorted( + { + "calls.view", + "calls.import", + "calls.delete", + "metrics.view", + "metrics.manage", + "evals.view", + "evals.run", + "sim.view", + "sim.manage", + "reports.view", + "reports.generate", + "workspace.settings", + "workspace.members.view", + "workspace.members.manage", + } + ), + ), +) + + +def _table_exists(db: Session, table_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_name = :table_name + """ + ), + {"table_name": table_name}, + ).first() + return row is not None + + +def _index_exists(db: Session, index_name: str) -> bool: + row = db.execute( + text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"), + {"index_name": index_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if not _table_exists(db, "workspace_roles"): + db.execute( + text( + """ + CREATE TABLE workspace_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL + REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + capabilities JSON NOT NULL DEFAULT '[]', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_workspace_roles_org_name + UNIQUE (organization_id, name) + ) + """ + ) + ) + print("Created workspace_roles table") + else: + print("workspace_roles table already exists, skipping CREATE...") + + # Belt-and-braces: ensure ``id`` has the DB-side DEFAULT even when the + # table was created by ``Base.metadata.create_all`` (init_db runs before + # migrations). create_all does not emit DEFAULT clauses for Python-side + # ``default=uuid.uuid4``, which leaves the column NOT NULL but with no + # default, breaking the raw-SQL seed below. + db.execute( + text( + "ALTER TABLE workspace_roles ALTER COLUMN id SET DEFAULT gen_random_uuid()" + ) + ) + + db.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_workspace_roles_organization_id " + "ON workspace_roles(organization_id)" + ) + ) + + if not _table_exists(db, "workspace_members"): + db.execute( + text( + """ + CREATE TABLE workspace_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL + REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL + REFERENCES workspace_roles(id) ON DELETE RESTRICT, + added_by_user_id UUID NULL + REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_workspace_members_ws_user + UNIQUE (workspace_id, user_id) + ) + """ + ) + ) + print("Created workspace_members table") + else: + print("workspace_members table already exists, skipping CREATE...") + + db.execute( + text( + "ALTER TABLE workspace_members ALTER COLUMN id SET DEFAULT gen_random_uuid()" + ) + ) + + for index_sql in ( + "CREATE INDEX IF NOT EXISTS ix_workspace_members_workspace_id " + "ON workspace_members(workspace_id)", + "CREATE INDEX IF NOT EXISTS ix_workspace_members_user_id " + "ON workspace_members(user_id)", + "CREATE INDEX IF NOT EXISTS ix_workspace_members_role_id " + "ON workspace_members(role_id)", + ): + db.execute(text(index_sql)) + + org_rows = db.execute(text("SELECT id FROM organizations")).fetchall() + for (org_id,) in org_rows: + role_ids: dict[str, str] = {} + for name, desc, caps in SYSTEM_ROLES: + existing = db.execute( + text( + """ + SELECT id FROM workspace_roles + WHERE organization_id = :org_id AND name = :name + """ + ), + {"org_id": org_id, "name": name}, + ).first() + if existing: + role_ids[name] = str(existing[0]) + continue + inserted = db.execute( + text( + """ + INSERT INTO workspace_roles + (id, organization_id, name, description, capabilities, is_system) + VALUES + (gen_random_uuid(), :org_id, :name, :description, + CAST(:capabilities AS JSON), TRUE) + RETURNING id + """ + ), + { + "org_id": org_id, + "name": name, + "description": desc, + "capabilities": json.dumps(caps), + }, + ).first() + role_ids[name] = str(inserted[0]) + print(f"Seeded system workspace roles for org {org_id}") + + workspaces = db.execute( + text("SELECT id FROM workspaces WHERE organization_id = :org_id"), + {"org_id": org_id}, + ).fetchall() + members = db.execute( + text( + """ + SELECT user_id, role FROM organization_members + WHERE organization_id = :org_id + """ + ), + {"org_id": org_id}, + ).fetchall() + + for workspace_id, in workspaces: + for user_id, org_role in members: + role_name = "Workspace Admin" + if org_role == "writer": + role_name = "Editor" + elif org_role == "reader": + role_name = "Viewer" + role_id = role_ids[role_name] + exists = db.execute( + text( + """ + SELECT 1 FROM workspace_members + WHERE workspace_id = :ws_id AND user_id = :user_id + """ + ), + {"ws_id": workspace_id, "user_id": user_id}, + ).first() + if exists: + continue + db.execute( + text( + """ + INSERT INTO workspace_members (id, workspace_id, user_id, role_id) + VALUES (gen_random_uuid(), :ws_id, :user_id, CAST(:role_id AS UUID)) + """ + ), + { + "ws_id": workspace_id, + "user_id": user_id, + "role_id": role_id, + }, + ) + print(f"Backfilled workspace memberships for org {org_id}") + + db.commit() + print("Workspace RBAC schema is in place") + + +def downgrade(db: Session): + db.execute(text("DROP TABLE IF EXISTS workspace_members")) + db.execute(text("DROP TABLE IF EXISTS workspace_roles")) + db.commit() diff --git a/app/models/database.py b/app/models/database.py index 336c966d..ceed0888 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -66,6 +66,11 @@ class Organization(Base): back_populates="organization", cascade="all, delete-orphan", ) + workspace_roles = relationship( + "WorkspaceRole", + back_populates="organization", + cascade="all, delete-orphan", + ) class Workspace(Base): @@ -74,8 +79,9 @@ class Workspace(Base): Every organization has at least one workspace (``is_default = True``, seeded by migration 033). Users pick an "active workspace" in the UI; list endpoints filter by it so users only see calls/metrics from the - project they're currently working in. There's no per-workspace ACL in - v1 - every org member can switch into any of their org's workspaces. + project they're currently working in. Access is governed by + ``workspace_members`` and org-scoped ``workspace_roles`` (capability + bundles); org admins implicitly access all workspaces. """ __tablename__ = "workspaces" @@ -121,6 +127,92 @@ class Workspace(Base): ) organization = relationship("Organization", back_populates="workspaces") + members = relationship( + "WorkspaceMember", + back_populates="workspace", + cascade="all, delete-orphan", + ) + + +class WorkspaceRole(Base): + """Org-scoped workspace role (system or custom) as a capability bundle.""" + + __tablename__ = "workspace_roles" + __table_args__ = ( + UniqueConstraint("organization_id", "name", name="uq_workspace_roles_org_name"), + ) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=text("gen_random_uuid()"), + ) + organization_id = Column( + UUID(as_uuid=True), + ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + capabilities = Column(JSON, nullable=False, default=list) + is_system = Column(Boolean, nullable=False, default=False, server_default="false") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + organization = relationship("Organization", back_populates="workspace_roles") + members = relationship("WorkspaceMember", back_populates="role") + + +class WorkspaceMember(Base): + """User membership in a workspace with an assigned workspace role.""" + + __tablename__ = "workspace_members" + __table_args__ = ( + UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_ws_user"), + ) + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id = Column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + role_id = Column( + UUID(as_uuid=True), + ForeignKey("workspace_roles.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + added_by_user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + workspace = relationship("Workspace", back_populates="members") + user = relationship("User", foreign_keys=[user_id]) + role = relationship("WorkspaceRole", back_populates="members") + added_by = relationship("User", foreign_keys=[added_by_user_id]) # Partial unique index: "at most one default workspace per org". This diff --git a/app/models/schemas.py b/app/models/schemas.py index 618d2247..c5025abb 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -4201,5 +4201,71 @@ class WorkspaceResponse(BaseModel): is_default: bool created_at: datetime updated_at: datetime + role_id: Optional[UUID] = None + role_name: Optional[str] = None + capabilities: List[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) + + +class WorkspaceRoleBase(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + capabilities: List[str] = Field(default_factory=list) + + +class WorkspaceRoleCreate(WorkspaceRoleBase): + pass + + +class WorkspaceRoleUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = None + capabilities: Optional[List[str]] = None + + +class WorkspaceRoleResponse(BaseModel): + id: UUID + organization_id: UUID + name: str + description: Optional[str] = None + capabilities: List[str] + is_system: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkspaceMemberResponse(BaseModel): + id: UUID + workspace_id: UUID + user_id: UUID + role_id: UUID + role_name: str + user_email: str + user_name: Optional[str] = None + added_by_user_id: Optional[UUID] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkspaceMemberCreate(BaseModel): + user_id: UUID + role_id: UUID + + +class WorkspaceMemberUpdate(BaseModel): + role_id: UUID + + +class CapabilityInfoResponse(BaseModel): + key: str + label: str + + +class CapabilityDomainResponse(BaseModel): + key: str + label: str + capabilities: List[CapabilityInfoResponse] diff --git a/app/services/organization_provisioning.py b/app/services/organization_provisioning.py index f052c5d1..54981021 100644 --- a/app/services/organization_provisioning.py +++ b/app/services/organization_provisioning.py @@ -7,6 +7,11 @@ from sqlalchemy.orm import Session from app.models.database import Workspace +from app.services.workspace_rbac import ( + backfill_org_workspace_memberships, + ensure_creator_workspace_admin, + seed_system_workspace_roles, +) def provision_default_workspace( @@ -16,6 +21,8 @@ def provision_default_workspace( created_by_user_id: UUID | None = None, ) -> Workspace: """Create the canonical Default workspace for an organization (idempotent).""" + seed_system_workspace_roles(db, organization_id=organization_id) + existing = ( db.query(Workspace) .filter( @@ -25,6 +32,7 @@ def provision_default_workspace( .first() ) if existing is not None: + backfill_org_workspace_memberships(db, organization_id=organization_id) return existing workspace = Workspace( @@ -36,4 +44,10 @@ def provision_default_workspace( ) db.add(workspace) db.flush() + ensure_creator_workspace_admin( + db, + workspace=workspace, + user_id=created_by_user_id, + ) + backfill_org_workspace_memberships(db, organization_id=organization_id) return workspace diff --git a/app/services/workspace_rbac.py b/app/services/workspace_rbac.py new file mode 100644 index 00000000..e6e6c969 --- /dev/null +++ b/app/services/workspace_rbac.py @@ -0,0 +1,239 @@ +"""Workspace RBAC helpers: role seeding, membership, capability resolution.""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.core.auth.capabilities import ( + EDITOR_ROLE_CAPABILITIES, + SYSTEM_ROLE_ADMIN, + SYSTEM_ROLE_EDITOR, + SYSTEM_ROLE_VIEWER, + VIEWER_ROLE_CAPABILITIES, + WORKSPACE_ADMIN_ROLE_CAPABILITIES, + normalize_capabilities, +) +from app.core.auth.rbac import get_org_role +from app.core.auth.principal import Principal +from app.models.database import ( + OrganizationMember, + RoleEnum, + Workspace, + WorkspaceMember, + WorkspaceRole, +) + + +SYSTEM_ROLE_DEFINITIONS: Tuple[Tuple[str, str, List[str]], ...] = ( + (SYSTEM_ROLE_VIEWER, "Read-only access to workspace resources.", VIEWER_ROLE_CAPABILITIES), + ( + SYSTEM_ROLE_EDITOR, + "View and modify workspace resources without admin settings.", + EDITOR_ROLE_CAPABILITIES, + ), + ( + SYSTEM_ROLE_ADMIN, + "Full access including workspace settings and member management.", + WORKSPACE_ADMIN_ROLE_CAPABILITIES, + ), +) + + +def seed_system_workspace_roles( + db: Session, + *, + organization_id: UUID, +) -> Dict[str, WorkspaceRole]: + """Ensure the three system roles exist for an organization (idempotent).""" + by_name: Dict[str, WorkspaceRole] = {} + for name, description, capabilities in SYSTEM_ROLE_DEFINITIONS: + role = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == name, + ) + .first() + ) + if role is None: + role = WorkspaceRole( + organization_id=organization_id, + name=name, + description=description, + capabilities=capabilities, + is_system=True, + ) + db.add(role) + db.flush() + by_name[name] = role + return by_name + + +def org_role_to_system_workspace_role(org_role: Optional[RoleEnum | str]) -> str: + """Map org membership role to a system workspace role name for backfill.""" + if isinstance(org_role, str): + try: + org_role = RoleEnum(org_role) + except ValueError: + org_role = RoleEnum.READER + if org_role == RoleEnum.ADMIN: + return SYSTEM_ROLE_ADMIN + if org_role == RoleEnum.WRITER: + return SYSTEM_ROLE_EDITOR + return SYSTEM_ROLE_VIEWER + + +def add_workspace_member( + db: Session, + *, + workspace_id: UUID, + user_id: UUID, + role_id: UUID, + added_by_user_id: UUID | None = None, +) -> WorkspaceMember: + """Add or update a workspace membership.""" + existing = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == user_id, + ) + .first() + ) + if existing is not None: + existing.role_id = role_id + if added_by_user_id is not None: + existing.added_by_user_id = added_by_user_id + db.flush() + return existing + + member = WorkspaceMember( + workspace_id=workspace_id, + user_id=user_id, + role_id=role_id, + added_by_user_id=added_by_user_id, + ) + db.add(member) + db.flush() + return member + + +def ensure_creator_workspace_admin( + db: Session, + *, + workspace: Workspace, + user_id: UUID | None, +) -> None: + """Auto-add workspace creator as Workspace Admin.""" + if user_id is None: + return + roles = seed_system_workspace_roles(db, organization_id=workspace.organization_id) + admin_role = roles[SYSTEM_ROLE_ADMIN] + add_workspace_member( + db, + workspace_id=workspace.id, + user_id=user_id, + role_id=admin_role.id, + added_by_user_id=user_id, + ) + + +def resolve_workspace_capabilities( + db: Session, + *, + principal: Principal, + workspace_id: UUID, + organization_id: UUID, +) -> Tuple[Set[str], Optional[WorkspaceMember], Optional[WorkspaceRole]]: + """ + Resolve the caller's capability set for a workspace. + + Returns (capabilities, membership_row, role_row). Org admins and unbound + API keys receive all capabilities with no membership row. + """ + from app.core.auth.capabilities import ALL_CAPABILITIES + + org_role = get_org_role(principal, db) + if org_role == RoleEnum.ADMIN: + return set(ALL_CAPABILITIES), None, None + + if principal.user_id is None: + return set(ALL_CAPABILITIES), None, None + + membership = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace_id, + WorkspaceMember.user_id == principal.user_id, + ) + .first() + ) + if membership is None: + return set(), None, None + + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == membership.role_id).first() + if role is None: + return set(), membership, None + + return normalize_capabilities(role.capabilities), membership, role + + +def is_workspace_admin_role(role: WorkspaceRole | None) -> bool: + if role is None: + return False + caps = normalize_capabilities(role.capabilities) + from app.core.auth.capabilities import WORKSPACE_SETTINGS, WORKSPACE_MEMBERS_MANAGE + + return WORKSPACE_SETTINGS in caps and WORKSPACE_MEMBERS_MANAGE in caps + + +def count_workspace_admins(db: Session, *, workspace_id: UUID) -> int: + """Count members whose role includes workspace admin capabilities.""" + members = ( + db.query(WorkspaceMember) + .filter(WorkspaceMember.workspace_id == workspace_id) + .all() + ) + count = 0 + for member in members: + role = db.query(WorkspaceRole).filter(WorkspaceRole.id == member.role_id).first() + if is_workspace_admin_role(role): + count += 1 + return count + + +def backfill_org_workspace_memberships(db: Session, *, organization_id: UUID) -> None: + """Add every org member to every org workspace (idempotent).""" + roles = seed_system_workspace_roles(db, organization_id=organization_id) + workspaces = ( + db.query(Workspace) + .filter(Workspace.organization_id == organization_id) + .all() + ) + members = ( + db.query(OrganizationMember) + .filter(OrganizationMember.organization_id == organization_id) + .all() + ) + for org_member in members: + role_name = org_role_to_system_workspace_role(org_member.role) + ws_role = roles[role_name] + for workspace in workspaces: + existing = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.workspace_id == workspace.id, + WorkspaceMember.user_id == org_member.user_id, + ) + .first() + ) + if existing is None: + add_workspace_member( + db, + workspace_id=workspace.id, + user_id=org_member.user_id, + role_id=ws_role.id, + ) diff --git a/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md b/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md new file mode 100644 index 00000000..e529cd3b --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-workspace-rbac-design.md @@ -0,0 +1,58 @@ +# Workspace RBAC: Capability-Based Access Control + +## Approved Design Decisions + +- **Default-closed**: org members only access workspaces they're explicitly granted; org admins implicitly access all workspaces. +- **Capability-based roles**: predefined system roles (Viewer, Editor, Workspace Admin) plus org-admin-definable custom roles composed from a fixed capability registry. +- **Membership management**: org admins + holders of `workspace.members.manage` in that workspace. +- **API keys stay org-wide** (bypass workspace RBAC); key scoping deferred. +- **Enforcement**: capability registry + role tables + per-route `require_capability()` dependencies. + +## Data Model + +### Capability registry (`app/core/auth/capabilities.py`) + +| Domain | Capabilities | +|---|---| +| Calls | `calls.view`, `calls.import`, `calls.delete` | +| Metrics | `metrics.view`, `metrics.manage` | +| Evaluations | `evals.view`, `evals.run` | +| Simulation | `sim.view`, `sim.manage` | +| Reports | `reports.view`, `reports.generate` | +| Workspace | `workspace.settings`, `workspace.members.view`, `workspace.members.manage` | + +### Tables + +- `workspace_roles`: `id`, `organization_id`, `name`, `description`, `capabilities` (JSON), `is_system`, timestamps; unique `(organization_id, name)` +- `workspace_members`: `id`, `workspace_id`, `user_id`, `role_id`, `added_by_user_id`, timestamps; unique `(workspace_id, user_id)` + +### System roles (seeded per org) + +- **Viewer** — all `*.view` capabilities +- **Editor** — Viewer + `calls.import`, `evals.run`, `metrics.manage`, `sim.manage`, `reports.generate` +- **Workspace Admin** — all capabilities + +### Backfill + +Every existing org member added to every org workspace: admin→Workspace Admin, writer→Editor, reader→Viewer. + +## Backend Enforcement + +- `WorkspaceContext` dependency resolves workspace + capability set +- Org admin → all capabilities; unbound API key → all; else membership lookup +- `require_capability(cap)` on workspace-scoped routes +- Org `ReaderReadOnlyMiddleware` unchanged + +## API + +- Workspace lifecycle: filtered list, create with auto-admin membership, settings/delete guards +- `workspace_iam.py`: members CRUD, workspace-roles CRUD, capabilities registry + +## Frontend + +- Filtered workspace switcher, members page, roles builder in IAM settings +- `useWorkspaceCapabilities()` hook for cosmetic UI gating + +## Rollout + +Backfill preserves existing access on upgrade; admins prune memberships via new UI. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a39e4072..37aa34e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,7 @@ import CronJobs from './pages/configurations/CronJobs' // IAM import IAM from './pages/iam/IAM' +import WorkspaceMembers from './pages/workspace/WorkspaceMembers' // Profile import Profile from './pages/profile/Profile' @@ -163,6 +164,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/CreateWorkspaceModal.tsx b/frontend/src/components/CreateWorkspaceModal.tsx new file mode 100644 index 00000000..361903c7 --- /dev/null +++ b/frontend/src/components/CreateWorkspaceModal.tsx @@ -0,0 +1,426 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, Plus, Trash2, Users, X } from 'lucide-react' +import { apiClient } from '../lib/api' +import type { OrganizationMember, Workspace, WorkspaceRole } from '../types/api' +import Button from './Button' +import { useToast } from '../hooks/useToast' +import { useAuthStore } from '../store/authStore' + +export interface PendingWorkspaceMember { + user_id: string + role_id: string + user_email: string + role_name: string +} + +interface CreateWorkspaceModalProps { + open: boolean + onClose: () => void + onCreated: (workspace: Workspace) => void | Promise +} + +export default function CreateWorkspaceModal({ + open, + onClose, + onCreated, +}: CreateWorkspaceModalProps) { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [pendingMembers, setPendingMembers] = useState([]) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const { showToast, ToastContainer } = useToast() + const currentUserId = useAuthStore((s) => s.user?.id ?? null) + + const { data: orgUsers = [] } = useQuery({ + queryKey: ['iam', 'users'], + queryFn: () => apiClient.listOrganizationUsers(), + enabled: open, + }) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + enabled: open, + }) + + const pendingUserIds = useMemo( + () => new Set(pendingMembers.map((m) => m.user_id)), + [pendingMembers], + ) + + const availableUsers = useMemo( + () => + orgUsers.filter( + (u) => !pendingUserIds.has(u.user_id) && u.user_id !== currentUserId, + ), + [orgUsers, pendingUserIds, currentUserId], + ) + + const defaultRoleId = useMemo(() => { + const viewer = roles.find((r) => r.name === 'Viewer') + return viewer?.id ?? roles[0]?.id ?? '' + }, [roles]) + + const activeRoleId = selectedRoleId || defaultRoleId + + useEffect(() => { + if (open && defaultRoleId && !selectedRoleId) { + setSelectedRoleId(defaultRoleId) + } + }, [open, defaultRoleId, selectedRoleId]) + + useEffect(() => { + if (!open) return + const previous = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = previous + } + }, [open]) + + const resetForm = () => { + setName('') + setSlug('') + setPendingMembers([]) + setSelectedUserId('') + setSelectedRoleId('') + setError('') + } + + const handleClose = () => { + if (submitting) return + resetForm() + onClose() + } + + const handleAddMember = () => { + if (!selectedUserId || !activeRoleId) { + setError('Select a user and role to add.') + return + } + const user = orgUsers.find((u) => u.user_id === selectedUserId) + const role = roles.find((r) => r.id === activeRoleId) + if (!user || !role) return + + setPendingMembers((prev) => [ + ...prev, + { + user_id: user.user_id, + role_id: role.id, + user_email: user.user.email, + role_name: role.name, + }, + ]) + setSelectedUserId('') + setSelectedRoleId(defaultRoleId) + setError('') + } + + const handleAddAllMembers = () => { + const role = roles.find((r) => r.id === activeRoleId) + if (!role) { + setError('Select a role before adding members.') + return + } + if (availableUsers.length === 0) { + setError('All org members are already in the list.') + return + } + + setPendingMembers((prev) => [ + ...prev, + ...availableUsers.map((user: OrganizationMember) => ({ + user_id: user.user_id, + role_id: role.id, + user_email: user.user.email, + role_name: role.name, + })), + ]) + setSelectedUserId('') + setError('') + } + + const handleRemoveMember = (userId: string) => { + setPendingMembers((prev) => prev.filter((m) => m.user_id !== userId)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const trimmedName = name.trim() + if (!trimmedName) { + setError('Workspace name is required.') + return + } + + setSubmitting(true) + setError('') + + try { + const created = await apiClient.createWorkspace({ + name: trimmedName, + ...(slug.trim() ? { slug: slug.trim() } : {}), + }) + + const failures: string[] = [] + for (const member of pendingMembers) { + try { + await apiClient.addWorkspaceMember(created.id, { + user_id: member.user_id, + role_id: member.role_id, + }) + } catch (memberErr: any) { + const detail = + memberErr?.response?.data?.detail || + `Could not add ${member.user_email}` + failures.push( + typeof detail === 'string' ? detail : `Could not add ${member.user_email}`, + ) + } + } + + resetForm() + await onCreated(created) + onClose() + + if (failures.length > 0) { + showToast( + `Workspace created, but some members could not be added: ${failures.join('; ')}`, + 'error', + ) + } else { + showToast('Workspace created', 'success') + } + } catch (err: any) { + const detail = err?.response?.data?.detail + setError( + typeof detail === 'string' ? detail : 'Could not create workspace.', + ) + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return createPortal( +
+ +
+
+
+
+

+ Create workspace +

+

+ Set a name and optionally invite org members. +

+
+ +
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="e.g. Project Phoenix" + className="w-full min-w-0 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + autoFocus + disabled={submitting} + /> +
+ +
+ + + setSlug( + e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, '_'), + ) + } + placeholder="Derived from name if empty" + className="w-full min-w-0 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={submitting} + /> +
+
+ +
+
+ +

+ You are added automatically as Workspace Admin. +

+
+ +
+ + + + + + + +
+ + {pendingMembers.length === 0 ? ( +

+ No additional members yet. You can add them now or later from + Workspace Members settings. +

+ ) : ( +
    + {pendingMembers.map((member) => ( +
  • +
    +
    + {member.user_email} +
    +
    + {member.role_name} +
    +
    + +
  • + ))} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d4513f74..5a57dc9e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -114,6 +114,7 @@ const navigationSections: NavSection[] = [ { name: 'VoiceBundle', href: '/voicebundles', icon: Mic }, { name: 'Integrations', href: '/integrations', icon: Plug }, { name: 'API Keys', href: '/settings', icon: Key }, + { name: 'Workspace Members', href: '/workspace-members', icon: Users }, { name: 'Cron Jobs', href: '/cron-jobs', icon: Clock }, ], }, diff --git a/frontend/src/components/WorkspaceRolesSection.tsx b/frontend/src/components/WorkspaceRolesSection.tsx new file mode 100644 index 00000000..03a57e63 --- /dev/null +++ b/frontend/src/components/WorkspaceRolesSection.tsx @@ -0,0 +1,216 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { apiClient } from '../lib/api' +import type { CapabilityDomain, CapabilityInfo, WorkspaceRole } from '../types/api' +import Button from './Button' +import { useToast } from '../hooks/useToast' + +export default function WorkspaceRolesSection() { + const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() + const [showCreate, setShowCreate] = useState(false) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [selectedCaps, setSelectedCaps] = useState>(new Set()) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + }) + + const { data: domains = [] } = useQuery({ + queryKey: ['capabilities'], + queryFn: () => apiClient.listCapabilities(), + }) + + const createMutation = useMutation({ + mutationFn: () => + apiClient.createWorkspaceRole({ + name, + description: description || undefined, + capabilities: Array.from(selectedCaps), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setShowCreate(false) + setName('') + setDescription('') + setSelectedCaps(new Set()) + showToast('Role created', 'success') + }, + onError: (error: any) => { + showToast(error.response?.data?.detail || 'Failed to create role', 'error') + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (roleId: string) => apiClient.deleteWorkspaceRole(roleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + showToast('Role deleted', 'success') + }, + onError: (error: any) => { + showToast(error.response?.data?.detail || 'Failed to delete role', 'error') + }, + }) + + const toggleCap = (cap: string) => { + setSelectedCaps((prev) => { + const next = new Set(prev) + if (next.has(cap)) next.delete(cap) + else next.add(cap) + return next + }) + } + + const systemRoles = useMemo( + () => roles.filter((r: WorkspaceRole) => r.is_system), + [roles], + ) + const customRoles = useMemo( + () => roles.filter((r: WorkspaceRole) => !r.is_system), + [roles], + ) + + return ( +
+ +
+
+

Workspace Roles

+

+ System roles are predefined. Org admins can create custom roles from capabilities. +

+
+ +
+ + {showCreate && ( +
+ setName(e.target.value)} + placeholder="Role name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + /> + setDescription(e.target.value)} + placeholder="Description (optional)" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + /> + + +
+ )} + + + deleteMutation.mutate(id)} + /> +
+ ) +} + +function RoleList({ + title, + roles, + onDelete, +}: { + title: string + roles: WorkspaceRole[] + onDelete?: (id: string) => void +}) { + if (!roles.length) return null + return ( +
+

{title}

+
+ {roles.map((role) => ( +
+
+
{role.name}
+ {role.description && ( +
{role.description}
+ )} +
+ {role.capabilities.length} capabilities +
+
+ {onDelete && ( + + )} +
+ ))} +
+
+ ) +} + +function CapabilityPicker({ + domains, + selected, + onToggle, +}: { + domains: CapabilityDomain[] + selected: Set + onToggle: (cap: string) => void +}) { + return ( +
+ {domains.map((domain) => ( +
+
+ {domain.label} +
+
+ {domain.capabilities.map((cap: CapabilityInfo) => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/WorkspaceSwitcher.tsx b/frontend/src/components/WorkspaceSwitcher.tsx index f68217d3..101df386 100644 --- a/frontend/src/components/WorkspaceSwitcher.tsx +++ b/frontend/src/components/WorkspaceSwitcher.tsx @@ -1,32 +1,20 @@ import { useEffect, useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Check, ChevronDown, FolderKanban, Loader2, Plus, X } from 'lucide-react' +import { Check, ChevronDown, FolderKanban, Plus } from 'lucide-react' import { apiClient } from '../lib/api' import type { Workspace } from '../types/api' +import { useCanWrite } from '../hooks/useRole' +import { useWorkspaceStore } from '../store/workspaceStore' +import CreateWorkspaceModal from './CreateWorkspaceModal' const ACTIVE_WORKSPACE_KEY = 'activeWorkspaceId' -/** - * Workspace switcher dropdown. - * - * Lives in the sidebar so the active workspace context is always visible. - * Persists the selection in `localStorage` under `activeWorkspaceId`; the - * shared axios client picks that up and forwards it as `X-Workspace-Id` on - * every request, which is how the backend's `get_workspace_id` dependency - * scopes listings. - * - * On any change (initial bootstrap into Default, or user-selected switch) - * we blow away every react-query cache so views refetch against the new - * workspace - no stale rows leaking between projects. - */ export default function WorkspaceSwitcher() { const queryClient = useQueryClient() + const canWrite = useCanWrite() + const setActiveCapabilities = useWorkspaceStore((s) => s.setActiveCapabilities) const [open, setOpen] = useState(false) - const [showCreate, setShowCreate] = useState(false) - const [newName, setNewName] = useState('') - const [newSlug, setNewSlug] = useState('') - const [creating, setCreating] = useState(false) - const [error, setError] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) const { data: workspaces = [], @@ -44,209 +32,156 @@ export default function WorkspaceSwitcher() { : null, ) - // Bootstrap: if we don't have a stored workspace, or the stored one - // doesn't belong to this org (e.g. after an org switch), fall back to - // the org's Default workspace. Migration 033 guarantees one exists. useEffect(() => { if (!workspaces.length) return const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY) const isValid = stored && workspaces.some((w) => w.id === stored) - if (isValid) { - if (stored !== activeId) setActiveId(stored) - return - } const fallback = - workspaces.find((w) => w.is_default) ?? workspaces[0] + (isValid ? workspaces.find((w) => w.id === stored) : null) ?? + workspaces.find((w) => w.is_default) ?? + workspaces[0] + if (!fallback) return - localStorage.setItem(ACTIVE_WORKSPACE_KEY, fallback.id) - setActiveId(fallback.id) - // Refetch workspace-scoped views now that the header will start being - // sent. Use a short delay so the localStorage write is visible to the - // axios request interceptor by the time queries refire. - queryClient.invalidateQueries() - }, [workspaces, activeId, queryClient]) + + if (!isValid) { + localStorage.setItem(ACTIVE_WORKSPACE_KEY, fallback.id) + setActiveId(fallback.id) + queryClient.invalidateQueries() + } else if (stored !== activeId) { + setActiveId(stored) + } + + setActiveCapabilities(fallback.capabilities ?? []) + }, [workspaces, activeId, queryClient, setActiveCapabilities]) const activeWorkspace = useMemo( () => workspaces.find((w) => w.id === activeId) ?? null, [workspaces, activeId], ) - const handleSelect = async (workspaceId: string) => { - if (workspaceId === activeId) { + useEffect(() => { + if (activeWorkspace?.capabilities) { + setActiveCapabilities(activeWorkspace.capabilities) + } + }, [activeWorkspace, setActiveCapabilities]) + + const handleSelect = async (workspace: Workspace) => { + if (workspace.id === activeId) { setOpen(false) return } - localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId) - setActiveId(workspaceId) + localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspace.id) + setActiveId(workspace.id) + setActiveCapabilities(workspace.capabilities ?? []) setOpen(false) - // Invalidate all queries so the UI refetches against the new - // workspace's data. Same pattern as OrgSwitcher. await queryClient.invalidateQueries() } - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault() - const trimmedName = newName.trim() - const trimmedSlug = newSlug.trim() - if (!trimmedName) { - setError('Name is required') - return - } - setError('') - setCreating(true) - try { - const created = await apiClient.createWorkspace({ - name: trimmedName, - // Slug is optional - the backend derives it from the name - // when omitted. - ...(trimmedSlug ? { slug: trimmedSlug } : {}), - }) - await refetch() - setShowCreate(false) - setNewName('') - setNewSlug('') - await handleSelect(created.id) - } catch (err: any) { - setError( - err?.response?.data?.detail || 'Could not create workspace', - ) - } finally { - setCreating(false) - } + const handleWorkspaceCreated = async (created: Workspace) => { + await refetch() + setOpen(false) + await handleSelect(created) + } + + const openCreateModal = () => { + setOpen(false) + setShowCreateModal(true) } return ( -
- - - {open && ( - <> -
{ - setOpen(false) - setShowCreate(false) - setError('') - }} - /> -
-
- Workspaces - -
+ <> +
+ - {showCreate && ( -
- setNewName(e.target.value)} - placeholder="Workspace name" - className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary-500" - autoFocus - /> - - setNewSlug( - e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-'), - ) - } - placeholder="slug (optional)" - className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary-500" - /> - {error && ( -
{error}
- )} - -
- )} - -
- {workspaces.length === 0 && !isLoading && ( -
- No workspaces yet. -
- )} - {workspaces.map((ws) => { - const isCurrent = ws.id === activeId - return ( + {open && ( + <> +
setOpen(false)} + /> +
+
+ Workspaces + {canWrite && ( - ) - })} + )} +
+ +
+ {workspaces.length === 0 && !isLoading && ( +
+ No workspaces available. + {canWrite && ( + + )} +
+ )} + {workspaces.map((ws) => { + const isCurrent = ws.id === activeId + return ( + + ) + })} +
-
- - )} -
+ + )} +
+ + setShowCreateModal(false)} + onCreated={handleWorkspaceCreated} + /> + ) } diff --git a/frontend/src/hooks/useWorkspaceCapabilities.ts b/frontend/src/hooks/useWorkspaceCapabilities.ts new file mode 100644 index 00000000..03353671 --- /dev/null +++ b/frontend/src/hooks/useWorkspaceCapabilities.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react' +import { useWorkspaceStore } from '../store/workspaceStore' + +/** Hook for cosmetic UI gating based on active workspace capabilities. */ +export function useWorkspaceCapabilities() { + const capabilities = useWorkspaceStore((s) => s.activeCapabilities) + + return useMemo( + () => ({ + capabilities, + has: (cap: string) => capabilities.includes(cap), + canViewMembers: capabilities.includes('workspace.members.view'), + canManageMembers: capabilities.includes('workspace.members.manage'), + canImportCalls: capabilities.includes('calls.import'), + canManageMetrics: capabilities.includes('metrics.manage'), + canRunEvals: capabilities.includes('evals.run'), + }), + [capabilities], + ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9caa105e..67d09be4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -353,6 +353,79 @@ class ApiClient { await this.client.delete(`/api/v1/workspaces/${workspaceId}`) } + async listCapabilities(): Promise { + const response = await this.client.get('/api/v1/capabilities') + return response.data + } + + async listWorkspaceRoles(): Promise { + const response = await this.client.get('/api/v1/workspace-roles') + return response.data + } + + async createWorkspaceRole( + payload: import('../types/api').WorkspaceRoleCreate, + ): Promise { + const response = await this.client.post('/api/v1/workspace-roles', payload) + return response.data + } + + async updateWorkspaceRole( + roleId: string, + payload: import('../types/api').WorkspaceRoleUpdate, + ): Promise { + const response = await this.client.patch( + `/api/v1/workspace-roles/${roleId}`, + payload, + ) + return response.data + } + + async deleteWorkspaceRole(roleId: string): Promise { + await this.client.delete(`/api/v1/workspace-roles/${roleId}`) + } + + async listWorkspaceMembers( + workspaceId: string, + ): Promise { + const response = await this.client.get( + `/api/v1/workspaces/${workspaceId}/members`, + ) + return response.data + } + + async addWorkspaceMember( + workspaceId: string, + payload: { user_id: string; role_id: string }, + ): Promise { + const response = await this.client.post( + `/api/v1/workspaces/${workspaceId}/members`, + payload, + ) + return response.data + } + + async updateWorkspaceMember( + workspaceId: string, + userId: string, + payload: { role_id: string }, + ): Promise { + const response = await this.client.patch( + `/api/v1/workspaces/${workspaceId}/members/${userId}`, + payload, + ) + return response.data + } + + async removeWorkspaceMember( + workspaceId: string, + userId: string, + ): Promise { + await this.client.delete( + `/api/v1/workspaces/${workspaceId}/members/${userId}`, + ) + } + // Auth endpoints async getAuthConfig(): Promise { const response = await this.client.get('/api/v1/auth/config') diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index 5c6e955c..116257ec 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -6,6 +6,7 @@ import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, Key import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' import { useIsAdmin } from '../../hooks/useRole' +import WorkspaceRolesSection from '../../components/WorkspaceRolesSection' export default function IAM() { const queryClient = useQueryClient() @@ -513,6 +514,12 @@ export default function IAM() {
+ {isAdmin && ( +
+ +
+ )} + {/* Invite Modal */} {showInviteModal && (
diff --git a/frontend/src/pages/workspace/WorkspaceMembers.tsx b/frontend/src/pages/workspace/WorkspaceMembers.tsx new file mode 100644 index 00000000..5065d140 --- /dev/null +++ b/frontend/src/pages/workspace/WorkspaceMembers.tsx @@ -0,0 +1,225 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Trash2, UserPlus, Users } from 'lucide-react' +import { apiClient } from '../../lib/api' +import { useWorkspaceCapabilities } from '../../hooks/useWorkspaceCapabilities' +import { useWorkspaceStore } from '../../store/workspaceStore' +import Button from '../../components/Button' +import { useToast } from '../../hooks/useToast' + +export default function WorkspaceMembers() { + const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() + const { canViewMembers, canManageMembers } = useWorkspaceCapabilities() + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) + const [showAdd, setShowAdd] = useState(false) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + + const { data: members = [], isLoading } = useQuery({ + queryKey: ['workspace-members', activeWorkspaceId], + queryFn: () => apiClient.listWorkspaceMembers(activeWorkspaceId!), + enabled: Boolean(activeWorkspaceId) && canViewMembers, + }) + + const { data: orgUsers = [] } = useQuery({ + queryKey: ['iam', 'users'], + queryFn: () => apiClient.listOrganizationUsers(), + enabled: canManageMembers, + }) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + }) + + const addMutation = useMutation({ + mutationFn: () => + apiClient.addWorkspaceMember(activeWorkspaceId!, { + user_id: selectedUserId, + role_id: selectedRoleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + setShowAdd(false) + setSelectedUserId('') + setSelectedRoleId('') + showToast('Member added', 'success') + }, + onError: (error: any) => { + showToast(error.response?.data?.detail || 'Failed to add member', 'error') + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + apiClient.updateWorkspaceMember(activeWorkspaceId!, userId, { + role_id: roleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Role updated', 'success') + }, + }) + + const removeMutation = useMutation({ + mutationFn: (userId: string) => + apiClient.removeWorkspaceMember(activeWorkspaceId!, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Member removed', 'success') + }, + }) + + if (!activeWorkspaceId) { + return ( +
+ Select a workspace from the sidebar to manage members. +
+ ) + } + + if (!canViewMembers) { + return ( +
+ You do not have permission to view workspace members. +
+ ) + } + + const memberUserIds = new Set(members.map((m) => m.user_id)) + const availableUsers = orgUsers.filter((u) => !memberUserIds.has(u.user_id)) + + return ( +
+ +
+
+

+ + Workspace Members +

+

+ Manage who can access the active workspace and their roles. +

+
+ {canManageMembers && ( + + )} +
+ + {showAdd && canManageMembers && ( +
+ + + +
+ )} + + {isLoading ? ( +
Loading members…
+ ) : ( +
+ + + + + + {canManageMembers && + + + {members.map((member) => ( + + + + {canManageMembers && ( + + )} + + ))} + +
+ User + + Role + } +
+
{member.user_email}
+ {member.user_name && ( +
{member.user_name}
+ )} +
+ {canManageMembers ? ( + + ) : ( + member.role_name + )} + + +
+
+ )} + +

+ Org admins can also manage{' '} + + workspace roles + {' '} + under IAM settings. +

+
+ ) +} diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts index d9e6f18a..31329745 100644 --- a/frontend/src/store/workspaceStore.ts +++ b/frontend/src/store/workspaceStore.ts @@ -1,25 +1,12 @@ import { create } from 'zustand' -/** - * Workspace store for the EfficientAI frontend. - * - * A workspace is the in-org isolation boundary for call imports and metrics - * (see migration 033 / app/api/v1/routes/workspaces.py). The active - * workspace id is sent on every API call as `X-Workspace-Id`; switching - * workspace + invalidating react-query caches is what scopes the UI to - * a different project's data. - * - * The store is intentionally thin - membership management and the workspace - * list itself live in react-query (`['workspaces']`). We only persist: - * - the currently selected workspace id (per-tab via localStorage), so a - * reload doesn't bounce the user back to "Default". - */ - const STORAGE_WORKSPACE_ID = 'activeWorkspaceId' interface WorkspaceState { activeWorkspaceId: string | null + activeCapabilities: string[] setActiveWorkspaceId: (id: string | null) => void + setActiveCapabilities: (capabilities: string[]) => void clearActiveWorkspaceId: () => void } @@ -33,6 +20,7 @@ function readStored(): string | null { export const useWorkspaceStore = create((set) => ({ activeWorkspaceId: readStored(), + activeCapabilities: [], setActiveWorkspaceId: (id: string | null) => { if (id) { @@ -43,17 +31,20 @@ export const useWorkspaceStore = create((set) => ({ set({ activeWorkspaceId: id }) }, + setActiveCapabilities: (capabilities: string[]) => { + set({ activeCapabilities: capabilities }) + }, + clearActiveWorkspaceId: () => { localStorage.removeItem(STORAGE_WORKSPACE_ID) - set({ activeWorkspaceId: null }) + set({ activeWorkspaceId: null, activeCapabilities: [] }) }, })) -/** - * Read the currently-selected workspace id without subscribing to the - * store. The axios request interceptor uses this so it doesn't have to - * hook into React state. - */ export function getActiveWorkspaceId(): string | null { return useWorkspaceStore.getState().activeWorkspaceId } + +export function hasWorkspaceCapability(capability: string): boolean { + return useWorkspaceStore.getState().activeCapabilities.includes(capability) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 1d3c49e3..9e4d9b4d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -842,6 +842,55 @@ export interface Workspace { is_default: boolean created_at: string updated_at: string + role_id?: string | null + role_name?: string | null + capabilities?: string[] +} + +export interface WorkspaceRole { + id: string + organization_id: string + name: string + description?: string | null + capabilities: string[] + is_system: boolean + created_at: string + updated_at: string +} + +export interface WorkspaceMember { + id: string + workspace_id: string + user_id: string + role_id: string + role_name: string + user_email: string + user_name?: string | null + added_by_user_id?: string | null + created_at: string +} + +export interface CapabilityInfo { + key: string + label: string +} + +export interface CapabilityDomain { + key: string + label: string + capabilities: CapabilityInfo[] +} + +export interface WorkspaceRoleCreate { + name: string + description?: string | null + capabilities: string[] +} + +export interface WorkspaceRoleUpdate { + name?: string + description?: string | null + capabilities?: string[] } export interface CallImport { diff --git a/tests/conftest.py b/tests/conftest.py index d8eaf7dd..bf507bc0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ """Shared pytest fixtures for backend tests.""" import os -import sys -import types -from contextlib import asynccontextmanager -from pathlib import Path -from uuid import uuid4 +import sys +import types +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 import pytest from fastapi import FastAPI @@ -274,11 +274,11 @@ def update_agent_prompt(self, **_kwargs): sys.modules["app.services.voice_agent.bot_fast_api"] = fake_bot_fast_api_module sys.modules["app.services.voice_agent.voice_bundle"] = fake_voice_bundle_module - if "app.services.reporting.voice_playground_report_service" not in sys.modules: - fake_reporting_pkg = types.ModuleType("app.services.reporting") - fake_reporting_pkg.__path__ = [ - str(Path(__file__).resolve().parents[1] / "app" / "services" / "reporting") - ] + if "app.services.reporting.voice_playground_report_service" not in sys.modules: + fake_reporting_pkg = types.ModuleType("app.services.reporting") + fake_reporting_pkg.__path__ = [ + str(Path(__file__).resolve().parents[1] / "app" / "services" / "reporting") + ] fake_report_service_module = types.ModuleType("app.services.reporting.voice_playground_report_service") class _FakeVoicePlaygroundReportService: @@ -332,10 +332,14 @@ class _TaskResult: from app.dependencies import ( get_api_key, get_organization_id, + get_workspace_context, get_workspace_id, require_enterprise_feature, + WorkspaceContext, ) - from app.models.database import Organization, Workspace + from app.core.auth.capabilities import ALL_CAPABILITIES + from app.models.database import Organization, OrganizationMember, RoleEnum, User, Workspace, APIKey + from app.services.workspace_rbac import backfill_org_workspace_memberships, seed_system_workspace_roles import app.dependencies as app_dependencies from app.api.v1.routes import ( aiproviders, @@ -374,6 +378,7 @@ class _TaskResult: voice_playground, voicebundles, workspaces, + workspace_iam, ) app = FastAPI() @@ -413,6 +418,15 @@ class _TaskResult: app.include_router(call_import_tags.router, prefix="/api/v1") app.include_router(call_import_evaluations.router, prefix="/api/v1") app.include_router(workspaces.router, prefix="/api/v1") + app.include_router(workspace_iam.router, prefix="/api/v1") + + def _override_workspace_context() -> WorkspaceContext: + return WorkspaceContext( + workspace_id=default_workspace.id, + organization_id=org_id, + capabilities=frozenset(ALL_CAPABILITIES), + is_org_admin=True, + ) # Enterprise route dependencies call app.dependencies.is_feature_enabled at runtime. # Force-enable it for API tests so tests remain focused on route behavior. @@ -456,15 +470,18 @@ def _ensure_default_workspace() -> Workspace: ) db_session.add(ws) db_session.commit() + seed_system_workspace_roles(db_session, organization_id=org_id) return ws default_workspace = _ensure_default_workspace() + backfill_org_workspace_memberships(db_session, organization_id=org_id) app.router.lifespan_context = _noop_lifespan app.dependency_overrides[get_db] = _override_get_db app.dependency_overrides[get_api_key] = lambda: api_key app.dependency_overrides[get_organization_id] = lambda: org_id app.dependency_overrides[get_workspace_id] = lambda: default_workspace.id + app.dependency_overrides[get_workspace_context] = _override_workspace_context app.dependency_overrides[require_enterprise_feature] = lambda: None with TestClient(app) as test_client: @@ -475,14 +492,45 @@ def _ensure_default_workspace() -> Workspace: @pytest.fixture def authenticated_client(client, api_key, db_session, org_id): - """Client pre-populated with auth header.""" - from app.models.database import Organization + """Client pre-populated with auth header and a real API key in the DB.""" + from app.models.database import APIKey, Organization, OrganizationMember, RoleEnum, User - # Authenticated routes usually persist rows scoped to organization_id. - # Ensure the organization exists to satisfy FK constraints on Postgres. existing_org = db_session.query(Organization).filter(Organization.id == org_id).first() if existing_org is None: db_session.add(Organization(id=org_id, name="Test Organization")) + db_session.flush() + + existing_key = ( + db_session.query(APIKey) + .filter(APIKey.key == api_key, APIKey.organization_id == org_id) + .first() + ) + if existing_key is None: + user = User( + id=uuid4(), + email="owner@example.com", + name="Org Owner", + is_active=True, + ) + db_session.add(user) + db_session.flush() + db_session.add( + OrganizationMember( + organization_id=org_id, + user_id=user.id, + role=RoleEnum.ADMIN.value, + ) + ) + db_session.add( + APIKey( + id=uuid4(), + key=api_key, + name="Test API Key", + organization_id=org_id, + user_id=user.id, + is_active=True, + ) + ) db_session.commit() client.headers.update({"X-API-Key": api_key}) diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 0c2d54a1..def88fc7 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -279,6 +279,27 @@ def _make_user(**overrides): @pytest.fixture def user_context(db_session, org_id, api_key, seed_org, make_user): """Seed user + org membership + API key for IAM/settings tests.""" + existing_key = ( + db_session.query(APIKey) + .filter(APIKey.key == api_key, APIKey.organization_id == org_id) + .first() + ) + if existing_key is not None and existing_key.user_id is not None: + user = db_session.query(User).filter(User.id == existing_key.user_id).first() + membership = ( + db_session.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.user_id == user.id, + ) + .first() + ) + if user.email != "owner@example.com": + user.email = "owner@example.com" + user.name = "Org Owner" + db_session.commit() + return {"user": user, "membership": membership, "api_key_record": existing_key} + user = make_user(email="owner@example.com", name="Org Owner") membership = OrganizationMember( id=uuid4(), diff --git a/tests/test_api/test_auth_routes.py b/tests/test_api/test_auth_routes.py index 8b8a7e1d..19c185bf 100644 --- a/tests/test_api/test_auth_routes.py +++ b/tests/test_api/test_auth_routes.py @@ -29,6 +29,39 @@ from app.services.organization_provisioning import provision_default_workspace +def _bind_api_key_to_user(db_session, *, api_key: str, org_id: UUID, user: User) -> APIKey: + """Point the shared test API key at ``user``, replacing any bootstrap binding.""" + db_session.query(APIKey).filter(APIKey.key == api_key).delete() + db_session.flush() + key = APIKey( + id=uuid4(), + key=api_key, + name="Test Key", + organization_id=org_id, + user_id=user.id, + is_active=True, + ) + db_session.add(key) + membership = ( + db_session.query(OrganizationMember) + .filter( + OrganizationMember.organization_id == org_id, + OrganizationMember.user_id == user.id, + ) + .first() + ) + if membership is None: + db_session.add( + OrganizationMember( + organization_id=org_id, + user_id=user.id, + role=RoleEnum.ADMIN.value, + ) + ) + db_session.commit() + return key + + # --------------------------------------------------------------------------- # Existing smoke tests (kept verbatim - they guard the API-key path). # --------------------------------------------------------------------------- @@ -448,23 +481,7 @@ def test_api_key_user_can_attach_password_and_real_email( ) db_session.add(user) db_session.flush() - db_session.add( - APIKey( - id=api_key_id, - key=api_key, - name="Bootstrap Key", - organization_id=org_id, - user_id=user.id, - is_active=True, - ) - ) - db_session.add( - OrganizationMember( - organization_id=org_id, - user_id=user.id, - role=RoleEnum.ADMIN.value, - ) - ) + _bind_api_key_to_user(db_session, api_key=api_key, org_id=org_id, user=user) db_session.commit() response = authenticated_client.post( @@ -497,24 +514,7 @@ def test_set_password_rejects_email_change_for_non_placeholder_user( ) db_session.add(user) db_session.flush() - db_session.add( - APIKey( - id=uuid4(), - key=api_key, - name="User Key", - organization_id=org_id, - user_id=user.id, - is_active=True, - ) - ) - db_session.add( - OrganizationMember( - organization_id=org_id, - user_id=user.id, - role=RoleEnum.ADMIN.value, - ) - ) - db_session.commit() + _bind_api_key_to_user(db_session, api_key=api_key, org_id=org_id, user=user) response = authenticated_client.post( "/api/v1/auth/password", @@ -541,23 +541,7 @@ def test_rotating_password_requires_current_password( ) db_session.add(user) db_session.flush() - db_session.add( - APIKey( - id=uuid4(), - key=api_key, - organization_id=org_id, - user_id=user.id, - is_active=True, - ) - ) - db_session.add( - OrganizationMember( - organization_id=org_id, - user_id=user.id, - role=RoleEnum.ADMIN.value, - ) - ) - db_session.commit() + _bind_api_key_to_user(db_session, api_key=api_key, org_id=org_id, user=user) # Missing current_password -> 401. missing_current = authenticated_client.post( diff --git a/tests/test_api/test_workspace_iam.py b/tests/test_api/test_workspace_iam.py new file mode 100644 index 00000000..5c9f60f6 --- /dev/null +++ b/tests/test_api/test_workspace_iam.py @@ -0,0 +1,216 @@ +"""Tests for workspace RBAC: roles, membership, and capability enforcement.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core.auth.capabilities import ( + CALLS_VIEW, + METRICS_VIEW, + SYSTEM_ROLE_ADMIN, + SYSTEM_ROLE_VIEWER, +) +from app.core.auth.principal import AuthMethod, Principal +from app.core.auth.dependency import get_principal +from app.database import get_db +from app.dependencies import get_organization_id, get_workspace_context, get_workspace_id +from app.models.database import ( + Organization, + OrganizationMember, + RoleEnum, + User, + Workspace, + WorkspaceMember, + WorkspaceRole, +) +from app.services.workspace_rbac import ( + add_workspace_member, + resolve_workspace_capabilities, + seed_system_workspace_roles, +) +from app.api.v1.routes import metrics, workspace_iam, workspaces + + +@pytest.fixture +def rbac_org(db_session): + org = Organization(id=uuid4(), name="RBAC Org") + db_session.add(org) + db_session.commit() + return org + + +@pytest.fixture +def rbac_users(db_session, rbac_org): + admin = User(id=uuid4(), email="admin@test.local", name="Admin") + viewer = User(id=uuid4(), email="viewer@test.local", name="Viewer") + db_session.add_all([admin, viewer]) + db_session.add_all( + [ + OrganizationMember( + organization_id=rbac_org.id, + user_id=admin.id, + role=RoleEnum.ADMIN, + ), + OrganizationMember( + organization_id=rbac_org.id, + user_id=viewer.id, + role=RoleEnum.READER, + ), + ] + ) + db_session.commit() + return {"admin": admin, "viewer": viewer} + + +@pytest.fixture +def rbac_workspace(db_session, rbac_org, rbac_users): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + ws = Workspace( + id=uuid4(), + organization_id=rbac_org.id, + name="Project A", + slug="project_a", + is_default=False, + ) + db_session.add(ws) + db_session.flush() + add_workspace_member( + db_session, + workspace_id=ws.id, + user_id=rbac_users["viewer"].id, + role_id=roles[SYSTEM_ROLE_VIEWER].id, + ) + db_session.commit() + return ws + + +def test_seed_system_roles_idempotent(db_session, rbac_org): + first = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + second = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + assert set(first.keys()) == {SYSTEM_ROLE_VIEWER, "Editor", SYSTEM_ROLE_ADMIN} + assert first[SYSTEM_ROLE_VIEWER].id == second[SYSTEM_ROLE_VIEWER].id + + +def test_resolve_capabilities_org_admin_bypass(db_session, rbac_org, rbac_users, rbac_workspace): + principal = Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["admin"].id, + ) + caps, membership, role = resolve_workspace_capabilities( + db_session, + principal=principal, + workspace_id=rbac_workspace.id, + organization_id=rbac_org.id, + ) + assert CALLS_VIEW in caps + assert METRICS_VIEW in caps + assert membership is None + assert role is None + + +def test_resolve_capabilities_non_member_empty(db_session, rbac_org, rbac_users, rbac_workspace): + outsider = User(id=uuid4(), email="out@test.local", name="Out") + db_session.add(outsider) + db_session.add( + OrganizationMember( + organization_id=rbac_org.id, + user_id=outsider.id, + role=RoleEnum.READER, + ) + ) + db_session.commit() + + principal = Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=outsider.id, + ) + caps, membership, role = resolve_workspace_capabilities( + db_session, + principal=principal, + workspace_id=rbac_workspace.id, + organization_id=rbac_org.id, + ) + assert caps == set() + assert membership is None + + +def test_list_workspaces_filtered_for_member(db_session, rbac_org, rbac_users, rbac_workspace): + app = FastAPI() + app.include_router(workspaces.router, prefix="/api/v1") + + def _principal(): + return Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["viewer"].id, + ) + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = _principal + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + response = client.get("/api/v1/workspaces") + assert response.status_code == 200 + names = {w["name"] for w in response.json()} + assert names == {"Project A"} + + +def test_workspace_members_require_view_capability( + db_session, rbac_org, rbac_users, rbac_workspace +): + app = FastAPI() + app.include_router(workspace_iam.router, prefix="/api/v1") + + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + ws_b = Workspace( + id=uuid4(), + organization_id=rbac_org.id, + name="Closed", + slug="closed", + is_default=False, + ) + db_session.add(ws_b) + db_session.commit() + + def _principal(): + return Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["viewer"].id, + ) + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = _principal + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + ok = client.get(f"/api/v1/workspaces/{rbac_workspace.id}/members") + denied = client.get(f"/api/v1/workspaces/{ws_b.id}/members") + + assert ok.status_code == 200 + assert denied.status_code == 403 + + add_workspace_member( + db_session, + workspace_id=ws_b.id, + user_id=rbac_users["viewer"].id, + role_id=roles[SYSTEM_ROLE_ADMIN].id, + ) + db_session.commit() + + with TestClient(app) as client: + allowed = client.get(f"/api/v1/workspaces/{ws_b.id}/members") + assert allowed.status_code == 200 diff --git a/tests/test_api/test_workspaces.py b/tests/test_api/test_workspaces.py index b72e1d2e..efef7581 100644 --- a/tests/test_api/test_workspaces.py +++ b/tests/test_api/test_workspaces.py @@ -18,9 +18,11 @@ Metric, Workspace, ) +from app.services.workspace_rbac import seed_system_workspace_roles def test_create_and_list_workspaces(authenticated_client, db_session, org_id): + seed_system_workspace_roles(db_session, organization_id=org_id) response = authenticated_client.post( "/api/v1/workspaces", json={"name": "Project Phoenix"} ) @@ -30,11 +32,10 @@ def test_create_and_list_workspaces(authenticated_client, db_session, org_id): assert body["slug"] == "project_phoenix" assert body["is_default"] is False assert body["organization_id"] == str(org_id) + assert body.get("capabilities") listing = authenticated_client.get("/api/v1/workspaces").json() - # Default sorts first (is_default DESC), then alphabetical by name. names = [w["name"] for w in listing] - assert names[0] == "Default" assert "Project Phoenix" in names From f9b5ab75c1c14a45488d23182a3efbad5e74643f Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Thu, 11 Jun 2026 19:09:42 +0000 Subject: [PATCH 02/12] feat: updating workspaces --- frontend/src/components/WorkspaceSwitcher.tsx | 43 ++++++++----------- frontend/src/store/workspaceStore.ts | 6 +++ 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/WorkspaceSwitcher.tsx b/frontend/src/components/WorkspaceSwitcher.tsx index 101df386..e242c6cb 100644 --- a/frontend/src/components/WorkspaceSwitcher.tsx +++ b/frontend/src/components/WorkspaceSwitcher.tsx @@ -7,11 +7,11 @@ import { useCanWrite } from '../hooks/useRole' import { useWorkspaceStore } from '../store/workspaceStore' import CreateWorkspaceModal from './CreateWorkspaceModal' -const ACTIVE_WORKSPACE_KEY = 'activeWorkspaceId' - export default function WorkspaceSwitcher() { const queryClient = useQueryClient() const canWrite = useCanWrite() + const activeId = useWorkspaceStore((s) => s.activeWorkspaceId) + const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace) const setActiveCapabilities = useWorkspaceStore((s) => s.setActiveCapabilities) const [open, setOpen] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false) @@ -26,15 +26,9 @@ export default function WorkspaceSwitcher() { staleTime: 60_000, }) - const [activeId, setActiveId] = useState(() => - typeof window !== 'undefined' - ? localStorage.getItem(ACTIVE_WORKSPACE_KEY) - : null, - ) - useEffect(() => { if (!workspaces.length) return - const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY) + const stored = activeId const isValid = stored && workspaces.some((w) => w.id === stored) const fallback = (isValid ? workspaces.find((w) => w.id === stored) : null) ?? @@ -44,35 +38,36 @@ export default function WorkspaceSwitcher() { if (!fallback) return if (!isValid) { - localStorage.setItem(ACTIVE_WORKSPACE_KEY, fallback.id) - setActiveId(fallback.id) + switchWorkspace(fallback.id, fallback.capabilities ?? []) queryClient.invalidateQueries() - } else if (stored !== activeId) { - setActiveId(stored) + return } - setActiveCapabilities(fallback.capabilities ?? []) - }, [workspaces, activeId, queryClient, setActiveCapabilities]) + const current = workspaces.find((w) => w.id === stored) + if (!current) return + + const nextCaps = current.capabilities ?? [] + const { activeCapabilities } = useWorkspaceStore.getState() + const capsChanged = + nextCaps.length !== activeCapabilities.length || + nextCaps.some((cap, i) => cap !== activeCapabilities[i]) + + if (capsChanged) { + setActiveCapabilities(nextCaps) + } + }, [workspaces, activeId, queryClient, switchWorkspace, setActiveCapabilities]) const activeWorkspace = useMemo( () => workspaces.find((w) => w.id === activeId) ?? null, [workspaces, activeId], ) - useEffect(() => { - if (activeWorkspace?.capabilities) { - setActiveCapabilities(activeWorkspace.capabilities) - } - }, [activeWorkspace, setActiveCapabilities]) - const handleSelect = async (workspace: Workspace) => { if (workspace.id === activeId) { setOpen(false) return } - localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspace.id) - setActiveId(workspace.id) - setActiveCapabilities(workspace.capabilities ?? []) + switchWorkspace(workspace.id, workspace.capabilities ?? []) setOpen(false) await queryClient.invalidateQueries() } diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts index 31329745..e320200a 100644 --- a/frontend/src/store/workspaceStore.ts +++ b/frontend/src/store/workspaceStore.ts @@ -7,6 +7,7 @@ interface WorkspaceState { activeCapabilities: string[] setActiveWorkspaceId: (id: string | null) => void setActiveCapabilities: (capabilities: string[]) => void + switchWorkspace: (id: string, capabilities?: string[]) => void clearActiveWorkspaceId: () => void } @@ -35,6 +36,11 @@ export const useWorkspaceStore = create((set) => ({ set({ activeCapabilities: capabilities }) }, + switchWorkspace: (id: string, capabilities: string[] = []) => { + localStorage.setItem(STORAGE_WORKSPACE_ID, id) + set({ activeWorkspaceId: id, activeCapabilities: capabilities }) + }, + clearActiveWorkspaceId: () => { localStorage.removeItem(STORAGE_WORKSPACE_ID) set({ activeWorkspaceId: null, activeCapabilities: [] }) From f1e7d76442edbfab86ebeb80993a3183670c7062 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Thu, 11 Jun 2026 19:36:01 +0000 Subject: [PATCH 03/12] feat: adding new models and provider --- app/config/models.json | 110 ++++++++++++++++++ app/models/enums.py | 1 + app/services/ai/llm_service.py | 4 + app/services/judge_alignment/model_catalog.py | 2 + frontend/public/fireworks.png | Bin 0 -> 8356 bytes .../providers/ProviderModelPicker.tsx | 1 + frontend/src/config/providers.ts | 5 + .../src/pages/configurations/Integrations.tsx | 1 + frontend/src/types/api.ts | 1 + .../test_services/test_ai/test_llm_service.py | 5 + 10 files changed, 130 insertions(+) create mode 100644 frontend/public/fireworks.png diff --git a/app/config/models.json b/app/config/models.json index 63ae8203..1392b0b8 100644 --- a/app/config/models.json +++ b/app/config/models.json @@ -71,6 +71,16 @@ "provider": "openai", "model_type": "llm" }, + "gpt-5.5": { + "provider": "openai", + "model_type": "llm", + "description": "OpenAI flagship — complex reasoning, agentic coding, 1M context" + }, + "gpt-5.5-pro": { + "provider": "openai", + "model_type": "llm", + "description": "Higher-accuracy GPT-5.5 variant with parallel test-time compute" + }, "tts-1": { "provider": "openai", "model_type": "tts" @@ -131,6 +141,106 @@ "provider": "anthropic", "model_type": "llm" }, + "claude-opus-4-7": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Opus 4.7 — stronger coding, vision, and complex multi-step tasks" + }, + "claude-opus-4-8": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Opus 4.8 — most capable Opus-tier model; 1M context, adaptive thinking" + }, + "claude-fable-5": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Fable 5 — Anthropic's most capable widely released model for demanding agentic work" + }, + "claude-mythos-5": { + "provider": "anthropic", + "model_type": "llm", + "description": "Claude Mythos 5 — Fable 5 capabilities; limited availability via Project Glasswing" + }, + "grok-4.3": { + "provider": "xai", + "model_type": "llm", + "description": "xAI flagship — low hallucination, agentic tool calling, 1M context" + }, + "grok-build-0.1": { + "provider": "xai", + "model_type": "llm", + "description": "xAI fast coding model trained for agentic coding (early access)" + }, + "grok-4.20-0309-reasoning": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 reasoning snapshot (Mar 2026)" + }, + "grok-4.20-0309-non-reasoning": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 non-reasoning snapshot (Mar 2026)" + }, + "grok-4.20-multi-agent-0309": { + "provider": "xai", + "model_type": "llm", + "description": "Grok 4.20 multi-agent snapshot (Mar 2026)" + }, + "deepseek-v4-pro": { + "provider": "fireworks", + "model_type": "llm", + "description": "DeepSeek V4 Pro — frontier MoE reasoning and coding (1M context)" + }, + "deepseek-v4-flash": { + "provider": "fireworks", + "model_type": "llm", + "description": "DeepSeek V4 Flash — fast extraction, classification, and search" + }, + "kimi-k2p6": { + "provider": "fireworks", + "model_type": "llm", + "description": "Kimi K2.6 — native multimodal agentic model for long-horizon coding" + }, + "kimi-k2p5": { + "provider": "fireworks", + "model_type": "llm", + "description": "Kimi K2.5 — unified vision/text agentic model with controllable reasoning" + }, + "glm-5p1": { + "provider": "fireworks", + "model_type": "llm", + "description": "GLM 5.1 — frontier open model for reasoning and agentic workflows" + }, + "minimax-m2p7": { + "provider": "fireworks", + "model_type": "llm", + "description": "MiniMax M2.7 — MoE model for complex agent harnesses and productivity tasks" + }, + "minimax-m2p5": { + "provider": "fireworks", + "model_type": "llm", + "description": "MiniMax M2.5 — fast coding and agentic tool use at low cost" + }, + "qwen3p6-plus": { + "provider": "fireworks", + "model_type": "llm", + "description": "Qwen 3.6 Plus — flagship multimodal model (Fireworks exclusive outside Alibaba)" + }, + "gpt-oss-120b": { + "provider": "fireworks", + "model_type": "llm", + "description": "OpenAI gpt-oss-120b — high-quality open-weight model for general reasoning" + }, + "gpt-oss-20b": { + "provider": "fireworks", + "model_type": "llm", + "description": "OpenAI gpt-oss-20b — fast open-weight model for chat and classification" + }, + "firefunction-v2": { + "provider": "fireworks", + "model_type": "llm", + "description": "FireFunction V2 — Fireworks function-calling optimized model" + }, "google-speech-v2": { "provider": "google", "model_type": "stt" diff --git a/app/models/enums.py b/app/models/enums.py index 096fd298..dba91cf1 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -115,6 +115,7 @@ class ModelProvider(str, enum.Enum): ANTHROPIC = "anthropic" GOOGLE = "google" XAI = "xai" + FIREWORKS = "fireworks" COHERE = "cohere" MISTRAL = "mistral" META = "meta" diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index ec40719b..701a6862 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -33,6 +33,8 @@ "aws": "bedrock", "deepseek": "deepseek", "groq": "groq", + "xai": "xai", + "fireworks": "fireworks_ai", } # Matches the model-name half of the Gemini 2.5 family: ``gemini-2.5-pro``, @@ -162,6 +164,8 @@ def _litellm_model_name(provider: ModelProvider, model: str) -> str: """Build the ``provider/model`` string that LiteLLM expects.""" provider_value = provider.value if hasattr(provider, "value") else str(provider) prefix = _LITELLM_PROVIDER_PREFIX.get(provider_value.lower(), provider_value.lower()) + if provider_value.lower() == "fireworks" and not model.startswith("accounts/"): + model = f"accounts/fireworks/models/{model}" return f"{prefix}/{model}" def generate_response( diff --git a/app/services/judge_alignment/model_catalog.py b/app/services/judge_alignment/model_catalog.py index 2c847181..e7f4afda 100644 --- a/app/services/judge_alignment/model_catalog.py +++ b/app/services/judge_alignment/model_catalog.py @@ -33,6 +33,7 @@ ModelProvider.ANTHROPIC.value, ModelProvider.GOOGLE.value, ModelProvider.XAI.value, + ModelProvider.FIREWORKS.value, ModelProvider.COHERE.value, ModelProvider.MISTRAL.value, ModelProvider.META.value, @@ -52,6 +53,7 @@ def _provider_label(provider_value: str) -> str: "anthropic": "Anthropic", "google": "Google", "xai": "xAI", + "fireworks": "Fireworks AI", "cohere": "Cohere", "mistral": "Mistral", "meta": "Meta", diff --git a/frontend/public/fireworks.png b/frontend/public/fireworks.png new file mode 100644 index 0000000000000000000000000000000000000000..f91b3802a08b837526e4846a10dabb12b897feaa GIT binary patch literal 8356 zcmeHt)k9QY)Hdo5>Fx&U21f>INkKY>kdzv_Ll{BG83!u!z)DmGrQ%9?<^#;64VviAvas z!@^>9R#TEUc$>Yq3^PtK>Jt8=tZ?vvJPC{JLl~8MhayuGO)Vea%&V7*HTwIFe#Hx* z>5;wdxpCP?GSfSLhndro69yCWIny!=%gb8wQjOFVrGj-$3$I^yyx1kGT}uI=QA*GBy_ zbcv()>ksXc6;^e%nfTQ-_*@!3Pg8>8zTGe^F!s1FF3+8W`qyUid2R0SSqz~vLgnEn zEAzd0E?i+fUtboAIC@o@YvX!Op$jWw(rPn#yc}#m$-rx24&F;4ixw4D68-jkw1kuz z8OAbc6jch#;Dv0;fzeBP$M&=_gHLQPk$dB`3VIIST^I}Var-B<0+brGOdgd1sbED; zG26IlSubID^b^J6_EX)3IA+U{bR3vPwH=6zkQ$#WZXrTj4xe;N6`oR4^Op%8-7Bo_ znk%ayduh0kol>3EU7LyP@WrZE6}Ryscph?eNSIG()1*!#I)sOmHow#-w2pAk4qX{# z#o@`b>aAS4yFuWx5ievj?$;ydH7I2RP29ZlmkdA^c`v0S6at<9~#i9MTC_F)|M&zUJKhIeuQGg{D?D!o) zlAZC$O6Cz>FvSBwN{#=X^BZfm-heStTlHdSPPJpTGM8dohenMleSL_}r2sfNux0g}$aOqE z>zc(L^y%%?XY2sJvNv3=Jvg2A9@<&KnVxx=Wvd{&rf8?va&VLW`SyzU8tIXX!29CiA9HY)hnx2nV*e=dC z+wU?sPGwz%SIsaX(NgVp<>GOd8WPp=hTByuvVD`Sh(jWO z_L{CrC|4VoP7!_rxxiS~W#Squ_9%TR&NZ5yvhN?TUwK=Amd6Yp1Po6K!Lq@9`z^tf zn`SK^0Qb=2b3M%Qa<6N>ZMI*?=52?}@S~5Ci+P4b`3TBZvt$>p?PsdcG@8K9LpxGC z((J~i2vvw0kwkIpU`az}jG+F_FoIlRrcuJR_u$?NME2)hShNmuqAEyK^&=e2Z6dnQ z2BxShh#&hng33VTjqRL*UuT&!B-Ur*zXklw!}enZN`)ykvK;|zQ{vZ5d;_9lD1ZzM zM3C~jY6V}MkX@6JUgcRmnR~N+B?tQWN&A&Cm9F#j3grduS(T&0$L@JZQ>;9(M?_e( z)oOdQi|3UVQ^A<1=v!1o>N7ie6z=7wM5DwYtirG%6Zb6;JrTAa%r2gt7f)Ta2-(;N zozho&?D-@!F?yhIT4;gIzB8fMI}i|*#pTQXc2>5BuHz06(O4E2Cvm5PlH3AFX~Vyw zb>VuLt0SWL^`BK!_O{N$%xUW6&s@0Zv(5Hnl=;fo62aT=Y{uw;jCSYAVEbiiL=+2^ zp`=(x5bg@@{&VzV#q;12;zm3~$dLV$UqslT|FS(?rPuL%^xfY}g5xTd?aFC1C%+}+ zTa=*Txw9zz_vcvfvUlPbDA^0fWZI(8!g>!j%}9lO2Eo6JEzPitoHye0Up1y{?y4-n zZhV@N+&_Y^9xaC9%xhwItP$FObnvRYR!<_utS;%38{WEuDTuOFvV`pFN@pDX_qo6n z?~Q>2MF1J>>{EGb*(i}Ur>mTaQBIlH5EI#GOq3p8cWJAGGP!Azi0WF3&^t<=IXQk> z&p08XUK~vp&AL_&{v%{*Dc@=H)#QzKJfxP<7DV=uvz$OhNG(YijB;nFFqhO4z4rOe3@rB})(WbCtK|nrW;m&MkI9GA$2Ed715?`aSj!3Q3%w2q zQ)fJ`eQvuuQ=cGm$qoXH9XuiT03NbU+G)kXVglhw?^PkAg(0=$`z=h4evsf%`}{uu zybF)b8db6sw1t|qLdg^jXh#43O}!64t%*(6b6V0z6qsKXcuXkmCfSTJ$SX)XdV8%i zUv`=$fAv+<>=Y1}Fx6KHo-L_^E+ghNC@li;wz`LBAvbbU_x?}ijF8rztj=vkU#Mf|Y8y!irMYQ+R zGwp%_atJ#1*^(QXHM+U$Wuh$pZP-b>Qua4?AuKcF&*=mnTe>X0dxmrAO;DvD@7V@s z&1dfNwOvwDL!$)62EuIIK7Yi~1~diWBtR~&mwiJ?AMVSz6yqm$L>$Aq944pE)Y};jhd93-u{-ueX}IR5-=rvZ1BbOdzG2@7X?`$dMf>?;hMsTMW~quD!eEq+n-BZKJye_!*U$XoT4=p)SmMYJoT8NE;`mOZpPIttrf*Y#SZISjdS;U~hYqv8^a0`xga&f6 z_@Z0Z)TYab0WN>-`Sgjhc~9K5kfl$~YO*AE+wr6LXXX=*9Bx^GC|5jkuVgT{5i$r5 z9^Hi$bJh3^Kgb`GSTQ}TS$UTeXoOw2wKKfEx!aBW0+X3-jsu{MtZtpv88DF!lZ>!9 zyeKVBLXZ|JlL3$AEd`Piw3qi)>)*%N3C-O;xh;Idvnf~sqP0@Um9HEZ8eXD>45Jr`N!qy-@ief4a;svDp>EZ)nq+zn zz_RO<%@nUW&4F}CZO|mZa29KVKtkc|r*yV0P&H6&abZe|NS>oQ6yR*d8fW>Y#TY)n zDYV8<85&=xsII=qKe1skIIzr8m`wkarWo~;^E-FE9f&Rzk!^bUUpVd0h`-!rcVXMR zBW1C9(~EzB{A7>lg=&_v@WkZW*2Oi>0BR^OAn+sp~nl zI27(EuD_A47(8xTbYE15gJj(Xd@Z}LEYbqMYfxi{wr)3VJAnF=iv@-d&ZR+#q)(uq zN|Js*qZ zU@+b;FO1~nn}EA6^(S(L-DYn z@8*uah^TQN6R7{WSnw~Rx!_9gK2AP-uFXfL-_3JYzD<~HM`~TuFzouPTnV}W>?RFl zn{cB<*)W~=^k?i@Uv4+A-f}3NS@<6oziMcm41rObCqK3JbobD@So}!PhS|Na)Z$1! zhG@5xm$u~4b}_B_(mNn^^wM;WtdpSc!L@qLW4xWBxKA1DhJEcW7;^zhrSGo(8mY@RNrW*x{pl0PaPeA_nfd7~;xV5SK3Cyv=)%$as=^tH z-?C$p*I~fBulsApkQXi$Iv298<2;Z_a^S__q#C9wAtAhAf_+gENsD5OjmVN`$~{WV zvt~n=UU)h0PlkwFO8ID~Lb+^~?fE-XdfbmkB3>196g5jGPuK!VvrKz+i`@J?J_`q@ zab^qP?z6x_x9+MuKsIu^5(_&Hhv5-SW4iQIeMV~rK8&|kydIqJxYBBF7*F^QsKUbg zwT4>=R6}3-{eeAdp08}}ob$GZO6MMhUW=>k6jH@Kd#DLYv1ms(VRZ@^B*|*sdZMiQGZc`S410! z8fC$j?3&tdax_O6YOhHaEU(1EOXC35a`&@~Roh6n$4X7ZvcSe%$zo|N9@b%1op;D=vcB`*=Prwd01bh$v$9n^8dPF=Y&$ zzFN3ku#|a!00?&RVub+kD7avX@|0J<-yIMkCpR_uc?)s?Y< zbAe@&GtWm%7~8FO8}rJ~x*P7i)z6hcjTd*Aus=COIr&@aUrV?v3c6~=Sfk0Sp54a8 z>c$&VP3Wl+vGbC!G1hkX+kzz0faGZNFZ#K!qw2$M3$n1P<+x6KF$fA#61TMTTErty;7 z?C0TJ2P|{-sNho-cp?Mf3cNGI-91D9WfX}ECjsoXgsHNDooc#>oPF89J5z7>{6uoU z%87|Z_gZKfakpRHI;Yz*Y5O1EPDm3cI^Xof#OdU;Epk|<@#N>i=!teKxZc<;ELz|! zdB>XwLTz|RZLa!fQFh6L&knr1q$(~jZq)^)63{#}A>a;pxV&0FuJsvSoEaY_8(Rp< z?31a{b#I|p{ODHcx05J*0}W_D=63X!>Wn9XM(ocx<2U4|Z`m))u1_3#@TETzb5-^S zFatjl(vx)O{8V$%rHE5rA;Z#`Fm?2y;ux&b8>eFBE{G@=>5#HUIK3Cc}CQuTpzIrRw`>^>%FhVdJ_$}iQX=6YoTiZWZtIhx? zJD*|8mg;17wMeTI2dAcS*RfAy|z$J9q(`3;jJsiQT3iM+Doj29?d$wHZXU ztep*mHn?+74p{WmL+imV1YDch{`ZrbRRI*sAq*!-bU%T*I2)_VLCnsN2*k<`J^=Rh3TG zbcOtakb6vFMdZk9zDGsrTg5abF3V#(qg&qDZDs=2tN7HWmOs`Us4#vBKoNaXd!{ja z{V{fo(cOSD!TD@v|Igz+=Ct`oRII^wljWCd7UW->J3IG`{-J=~XpgEsWn7j`=>k0s zTr|?x59ktik*Gy9?t4FUcVT4xo3k&=F93h-0342l==C|E<^lF_g;Z^91Br(VfbaPa z2l&^Gs$cIVS4^AyUc%^NnMkWmv+AujduQ5%MHzE{_+Gmbr9!_=04h`Wc75II7yA@v z;BATaY$Mg!#eeOt|BDR)cHa~a2M6r?>&CmNfd+4uZ|1gSZm{#kmo8-3fa%JtrtpYn zF0^!|x5CT`$RnjTGdg@U{&>JesO!#Ow?t>*U6;-!gZi&J6lpA{=#}AN561MTE!hb&u7rEGOY~x3}J=t|>Sw+>CiPikd_Ln>1l+VY;N8dm{stLKkAdm(~>Z6Aj1 zxEreJw|-XFG}Vn>_CKZE-E)Wtqr<8goYKH86XzpfvCd%<9?5pm(a@G#U-~1oyT^Zo zZQj2nHJx3>3p0+}Xu|hB+&>7)SBSIubeE`%CkH;bR-i8!waeK*U+h|*$x3&>V6qE& z#QHkV>hTbtah6)r9=IHz#WolwCkyuW`mKRht$EHl{d3kwPZ-1*HU8x}OSvn&>eQ{J zTl4cSrH;>-?y{mXePd|Ua1l^pUCaKIG(I(t@X?alaw!tl2Gl}ta+oAjfSH@` z*b~98GkfI=z?TULv17J(eX3WXtgeTM9PgJ=ExDH?g+}>6hwxA2?AS_xB>EA-*Q2d? ze~o=^0e61qGbY9k-^|d3Jm-!i#U9yHYIAJ2u#b@woH;nfky%;=yLm!P36FQ~-kPBI z1-zF-7R47AM-CiM_UyYX|E5L^7jZN-t8kBG_ORXgR8{%BA49qucT>q2^JK`^1`6Qd zv1zKgZWepEV0H7+>+i)VC}YcnpsDlItUcJX*`v?cPA4QLhfT{iP34D|XqQ1iJj{QH`l zr^taM8PAx>a(Xt`TdL@yAv&UOFuAC=8_mc0$e&z91j8+8z z5thSaaBxPI=;~nLE3y zoi9gV8%KPjZuiOy}*d_-<$gUp(Ta& z$XI65^l6bJa#iD(j*z@UpueBAhkr%w2?1)}Llk)G?n_j*m;0rG@JPJcXE?rz{U9m1 zGmyOlq+T7s%*QH|2@lO-RLxwLb<4B6q|>%nn>yy`+1UX11)C9LvxiOho+s*WPTQ>8 zW@1-10Ig)G&s0-D12A^7&0!Myr;)x5*4!}tdvwNEz%;1xLX%+ErS++_19!f}clUem z=Hk7UO7EO5{lUB4)87n0^Aj#Fd;1L;4?B-&PqA}LBF$Tn+^~m3582Y%Jba&rlj$}% zZwPgcZqANqHdxc9AEJVR+%e%iSSs zamWJ_0T95htpZJ#F9kpY;Me9}Bpa%h!Ttl<#Ls~#J+L|=QT5F)lEAQ+f#NOzTI&P4 z=Y&p$Kg%2vYLmfT9uSEkiXas%cz!Q`t!{_#m23H=%S`xi+^W|BK-l#7gr*9UEj#DY zhRx*vieoLKIW={Y6mZq-DE$u#672j#7tThL1Q#9NhvBiie?7P@Jc))TUKm!}QD)|H zAc80g1a!*^BTea)FIyIe52V+ZYBP12b^gufnf5QFJ-^;fO~Q`K*ar4`CxM8u5KCBQ^wsbP2e`501iu%m-Ko zq`3&`3{1DbktU1G>PS@5Y_`(_GUD{A?6bV!Z!N`{MAe=0Cujmud7tMirgkJ3gp(Sa zVw+j(hJWUQ*UFXnX@P-HV0gR1=i8*Hw!C&exPufqp^?oql9;{^V8w0~dv zqPtYk_@W5M#nBGNxu4);b`c+RblgBViw7;L%u5TjkFGJ6m)@cYV%bgjX~G>3q<%KV zc>rrM91&JMu3a29&n|?i&fZxw+7>z;EQ?0OQEnLbnRvVZbnlgU6z`tBI~xUybV#q} zYuDyg(<0q{n@>4Pgqgy&W`sWXzIH_bfy?TX!HL(e&JypHQEb##^>GA-znKG)%okw* z?@|P~kdx3{Pu)G4PUr5+n{PU@Dru>cT=G^zM&oJUYq6=3+6nR;V5gK+ynUcecmiPc zg7?tTV7bRcPgv2$BzJg(PXsK12P(q1y(0AOee23kwPfvAZDKpxySQvlxr21$KpTy^ zm{<|D#BG%7l_4PLyqorGQ`*+j)qr;O(BFh}sI2&RT(}+_I(y1A(3Y6H*>|3@nYkDZ z9&eckYnJrN;HbkS5rJE8-KkEBl-F3E{w%sE8Y*FU%M#_>V_T~J$BKy?peFxdqk#gT zi8KZFAdy-a3GwcQ+|GJTkX2dZPHL&Xy%Jp+Tr(5_I=sEsxzVzY<0y%>lj?2ezrA-i z&TGsOCFV?tf-) = { anthropic: 'Anthropic', openrouter: 'OpenRouter', xai: 'xAI', + fireworks: 'Fireworks AI', google: 'Google', cohere: 'Cohere', mistral: 'Mistral', diff --git a/frontend/src/config/providers.ts b/frontend/src/config/providers.ts index 58e2211c..6f948b80 100644 --- a/frontend/src/config/providers.ts +++ b/frontend/src/config/providers.ts @@ -32,6 +32,11 @@ export const MODEL_PROVIDER_CONFIG: Record = { logo: '/xai.svg', description: 'Grok models via xAI', }, + [ModelProvider.FIREWORKS]: { + label: 'Fireworks AI', + logo: '/fireworks.png', + description: 'Hosted open-source LLMs via Fireworks', + }, [ModelProvider.COHERE]: { label: 'Cohere', logo: '/cohere.svg', diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index ab22db81..bec11b6a 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -24,6 +24,7 @@ const AI_INTEGRATION_PROVIDERS: ModelProvider[] = [ ModelProvider.ANTHROPIC, ModelProvider.GOOGLE, ModelProvider.XAI, + ModelProvider.FIREWORKS, ModelProvider.COHERE, ModelProvider.MISTRAL, ModelProvider.META, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 9e4d9b4d..5e7fdb6a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -213,6 +213,7 @@ export enum ModelProvider { ANTHROPIC = 'anthropic', GOOGLE = 'google', XAI = 'xai', + FIREWORKS = 'fireworks', COHERE = 'cohere', MISTRAL = 'mistral', META = 'meta', diff --git a/tests/test_services/test_ai/test_llm_service.py b/tests/test_services/test_ai/test_llm_service.py index a6352728..580bd2ad 100644 --- a/tests/test_services/test_ai/test_llm_service.py +++ b/tests/test_services/test_ai/test_llm_service.py @@ -16,6 +16,11 @@ def test_litellm_model_name_maps_known_provider_prefixes(): assert LLMService._litellm_model_name(ModelProvider.OPENAI, "gpt-4o") == "openai/gpt-4o" assert LLMService._litellm_model_name(ModelProvider.GOOGLE, "gemini-1.5-pro") == "gemini/gemini-1.5-pro" assert LLMService._litellm_model_name(ModelProvider.AWS, "claude") == "bedrock/claude" + assert LLMService._litellm_model_name(ModelProvider.XAI, "grok-4.3") == "xai/grok-4.3" + assert ( + LLMService._litellm_model_name(ModelProvider.FIREWORKS, "deepseek-v4-pro") + == "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro" + ) def test_generate_response_raises_when_provider_not_configured(monkeypatch): From 8950dcbb1dda6c21b265ecc5fb15453f231ce5ab Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Thu, 11 Jun 2026 19:44:40 +0000 Subject: [PATCH 04/12] fix: updating workspace members --- frontend/src/App.tsx | 6 +- frontend/src/components/Layout.tsx | 1 - .../src/components/WorkspaceRolesSection.tsx | 2 +- .../iam/WorkspaceMembersSection.tsx | 316 ++++++++++++++++++ frontend/src/pages/iam/IAM.tsx | 71 +++- .../src/pages/workspace/WorkspaceMembers.tsx | 225 ------------- 6 files changed, 386 insertions(+), 235 deletions(-) create mode 100644 frontend/src/components/iam/WorkspaceMembersSection.tsx delete mode 100644 frontend/src/pages/workspace/WorkspaceMembers.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37aa34e3..be5947b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,7 +64,6 @@ import CronJobs from './pages/configurations/CronJobs' // IAM import IAM from './pages/iam/IAM' -import WorkspaceMembers from './pages/workspace/WorkspaceMembers' // Profile import Profile from './pages/profile/Profile' @@ -164,7 +163,10 @@ function App() { } /> } /> } /> - } /> + } + /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5a57dc9e..d4513f74 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -114,7 +114,6 @@ const navigationSections: NavSection[] = [ { name: 'VoiceBundle', href: '/voicebundles', icon: Mic }, { name: 'Integrations', href: '/integrations', icon: Plug }, { name: 'API Keys', href: '/settings', icon: Key }, - { name: 'Workspace Members', href: '/workspace-members', icon: Users }, { name: 'Cron Jobs', href: '/cron-jobs', icon: Clock }, ], }, diff --git a/frontend/src/components/WorkspaceRolesSection.tsx b/frontend/src/components/WorkspaceRolesSection.tsx index 03a57e63..25429236 100644 --- a/frontend/src/components/WorkspaceRolesSection.tsx +++ b/frontend/src/components/WorkspaceRolesSection.tsx @@ -74,7 +74,7 @@ export default function WorkspaceRolesSection() { ) return ( -
+
diff --git a/frontend/src/components/iam/WorkspaceMembersSection.tsx b/frontend/src/components/iam/WorkspaceMembersSection.tsx new file mode 100644 index 00000000..cfc58e60 --- /dev/null +++ b/frontend/src/components/iam/WorkspaceMembersSection.tsx @@ -0,0 +1,316 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ChevronDown, FolderKanban, Trash2, UserPlus, Users } from 'lucide-react' +import { apiClient } from '../../lib/api' +import { useWorkspaceStore } from '../../store/workspaceStore' +import Button from '../Button' +import { useToast } from '../../hooks/useToast' + +function workspaceCaps(caps: string[] | undefined) { + const list = caps ?? [] + return { + canViewMembers: list.includes('workspace.members.view'), + canManageMembers: list.includes('workspace.members.manage'), + } +} + +export default function WorkspaceMembersSection() { + const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + const [showAdd, setShowAdd] = useState(false) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + + const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ + queryKey: ['workspaces'], + queryFn: () => apiClient.listWorkspaces(), + }) + + useEffect(() => { + if (!workspaces.length) return + const stillValid = + selectedWorkspaceId && workspaces.some((w) => w.id === selectedWorkspaceId) + if (stillValid) return + + const fallback = + workspaces.find((w) => w.id === activeWorkspaceId) ?? workspaces[0] + setSelectedWorkspaceId(fallback.id) + }, [workspaces, activeWorkspaceId, selectedWorkspaceId]) + + const selectedWorkspace = useMemo( + () => workspaces.find((w) => w.id === selectedWorkspaceId) ?? null, + [workspaces, selectedWorkspaceId], + ) + + const { canViewMembers, canManageMembers } = workspaceCaps( + selectedWorkspace?.capabilities, + ) + + const { data: members = [], isLoading: membersLoading } = useQuery({ + queryKey: ['workspace-members', selectedWorkspaceId], + queryFn: () => apiClient.listWorkspaceMembers(selectedWorkspaceId!), + enabled: Boolean(selectedWorkspaceId) && canViewMembers, + }) + + const { data: orgUsers = [] } = useQuery({ + queryKey: ['iam', 'users'], + queryFn: () => apiClient.listOrganizationUsers(), + enabled: canManageMembers, + }) + + const { data: roles = [] } = useQuery({ + queryKey: ['workspace-roles'], + queryFn: () => apiClient.listWorkspaceRoles(), + enabled: canViewMembers, + }) + + const addMutation = useMutation({ + mutationFn: () => + apiClient.addWorkspaceMember(selectedWorkspaceId!, { + user_id: selectedUserId, + role_id: selectedRoleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + setShowAdd(false) + setSelectedUserId('') + setSelectedRoleId('') + showToast('Member added', 'success') + }, + onError: (error: any) => { + showToast(error.response?.data?.detail || 'Failed to add member', 'error') + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + apiClient.updateWorkspaceMember(selectedWorkspaceId!, userId, { + role_id: roleId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Role updated', 'success') + }, + }) + + const removeMutation = useMutation({ + mutationFn: (userId: string) => + apiClient.removeWorkspaceMember(selectedWorkspaceId!, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) + showToast('Member removed', 'success') + }, + }) + + const memberUserIds = new Set(members.map((m) => m.user_id)) + const availableUsers = orgUsers.filter((u) => !memberUserIds.has(u.user_id)) + + return ( +
+ +
+
+

+ + Workspace Members +

+

+ Choose a workspace to view members and assign workspace roles. +

+
+ {canManageMembers && ( + + )} +
+ +
+
+ +
+ + + +
+ {selectedWorkspace && ( +

+ Slug: {selectedWorkspace.slug} + {selectedWorkspace.role_name && ( + <> + {' '} + · Your role:{' '} + + {selectedWorkspace.role_name} + + + )} +

+ )} +
+ + {!selectedWorkspaceId ? ( +
+ {workspacesLoading ? 'Loading workspaces…' : 'Select a workspace.'} +
+ ) : !canViewMembers ? ( +
+ You do not have permission to view members for this workspace. +
+ ) : ( + <> + {showAdd && canManageMembers && ( +
+ + + +
+ )} + + {membersLoading ? ( +
+ Loading members… +
+ ) : ( +
+ + + + + + {canManageMembers && + + + {members.length === 0 ? ( + + + + ) : ( + members.map((member) => ( + + + + {canManageMembers && ( + + )} + + )) + )} + +
+ User + + Role + } +
+ No members in this workspace yet. +
+
+ {member.user_email} +
+ {member.user_name && ( +
+ {member.user_name} +
+ )} +
+ {canManageMembers ? ( + + ) : ( + member.role_name + )} + + +
+
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index 116257ec..bd21f5ac 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -1,17 +1,45 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../lib/api' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { Role, Invitation, OrganizationMember, InvitationCreate } from '../../types/api' import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, KeyRound, Eye, EyeOff, Building2, Copy, Check } from 'lucide-react' import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' import { useIsAdmin } from '../../hooks/useRole' import WorkspaceRolesSection from '../../components/WorkspaceRolesSection' +import WorkspaceMembersSection from '../../components/iam/WorkspaceMembersSection' + +type IamTab = 'organization' | 'workspace-members' | 'workspace-roles' + +const IAM_TABS: { id: IamTab; label: string; icon: typeof Building2; adminOnly?: boolean }[] = [ + { id: 'organization', label: 'Organization', icon: Building2 }, + { id: 'workspace-members', label: 'Workspace Members', icon: Users }, + { id: 'workspace-roles', label: 'Workspace Roles', icon: Shield, adminOnly: true }, +] export default function IAM() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const isAdmin = useIsAdmin() + const [searchParams, setSearchParams] = useSearchParams() + const tabParam = searchParams.get('tab') + const activeTab: IamTab = + tabParam === 'workspace-members' || tabParam === 'workspace-roles' + ? tabParam + : 'organization' + + const visibleTabs = IAM_TABS.filter((t) => !t.adminOnly || isAdmin) + + useEffect(() => { + if (activeTab === 'workspace-roles' && !isAdmin) { + setSearchParams({ tab: 'organization' }, { replace: true }) + } + }, [activeTab, isAdmin, setSearchParams]) + + const setActiveTab = (tab: IamTab) => { + setSearchParams(tab === 'organization' ? {} : { tab }) + } const [showInviteModal, setShowInviteModal] = useState(false) const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState(Role.READER) @@ -238,12 +266,17 @@ export default function IAM() {

Identity & Access Management

- {isAdmin - ? 'Manage users and their permissions in your organization' - : 'View users and pending invitations in your organization'} + {activeTab === 'organization' && + (isAdmin + ? 'Manage organization users, invitations, and settings' + : 'View users and pending invitations in your organization')} + {activeTab === 'workspace-members' && + 'Manage workspace access and assign roles per workspace'} + {activeTab === 'workspace-roles' && + 'Configure workspace roles and their capabilities'}

- {isAdmin && ( + {isAdmin && activeTab === 'organization' && (
+
+ +
+ + {activeTab === 'organization' && ( + <> {isAdmin && (
@@ -513,8 +568,12 @@ export default function IAM() { )}
+ + )} - {isAdmin && ( + {activeTab === 'workspace-members' && } + + {activeTab === 'workspace-roles' && isAdmin && (
diff --git a/frontend/src/pages/workspace/WorkspaceMembers.tsx b/frontend/src/pages/workspace/WorkspaceMembers.tsx deleted file mode 100644 index 5065d140..00000000 --- a/frontend/src/pages/workspace/WorkspaceMembers.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' -import { Link } from 'react-router-dom' -import { Trash2, UserPlus, Users } from 'lucide-react' -import { apiClient } from '../../lib/api' -import { useWorkspaceCapabilities } from '../../hooks/useWorkspaceCapabilities' -import { useWorkspaceStore } from '../../store/workspaceStore' -import Button from '../../components/Button' -import { useToast } from '../../hooks/useToast' - -export default function WorkspaceMembers() { - const queryClient = useQueryClient() - const { showToast, ToastContainer } = useToast() - const { canViewMembers, canManageMembers } = useWorkspaceCapabilities() - const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) - const [showAdd, setShowAdd] = useState(false) - const [selectedUserId, setSelectedUserId] = useState('') - const [selectedRoleId, setSelectedRoleId] = useState('') - - const { data: members = [], isLoading } = useQuery({ - queryKey: ['workspace-members', activeWorkspaceId], - queryFn: () => apiClient.listWorkspaceMembers(activeWorkspaceId!), - enabled: Boolean(activeWorkspaceId) && canViewMembers, - }) - - const { data: orgUsers = [] } = useQuery({ - queryKey: ['iam', 'users'], - queryFn: () => apiClient.listOrganizationUsers(), - enabled: canManageMembers, - }) - - const { data: roles = [] } = useQuery({ - queryKey: ['workspace-roles'], - queryFn: () => apiClient.listWorkspaceRoles(), - }) - - const addMutation = useMutation({ - mutationFn: () => - apiClient.addWorkspaceMember(activeWorkspaceId!, { - user_id: selectedUserId, - role_id: selectedRoleId, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) - setShowAdd(false) - setSelectedUserId('') - setSelectedRoleId('') - showToast('Member added', 'success') - }, - onError: (error: any) => { - showToast(error.response?.data?.detail || 'Failed to add member', 'error') - }, - }) - - const updateMutation = useMutation({ - mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => - apiClient.updateWorkspaceMember(activeWorkspaceId!, userId, { - role_id: roleId, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) - showToast('Role updated', 'success') - }, - }) - - const removeMutation = useMutation({ - mutationFn: (userId: string) => - apiClient.removeWorkspaceMember(activeWorkspaceId!, userId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['workspace-members'] }) - showToast('Member removed', 'success') - }, - }) - - if (!activeWorkspaceId) { - return ( -
- Select a workspace from the sidebar to manage members. -
- ) - } - - if (!canViewMembers) { - return ( -
- You do not have permission to view workspace members. -
- ) - } - - const memberUserIds = new Set(members.map((m) => m.user_id)) - const availableUsers = orgUsers.filter((u) => !memberUserIds.has(u.user_id)) - - return ( -
- -
-
-

- - Workspace Members -

-

- Manage who can access the active workspace and their roles. -

-
- {canManageMembers && ( - - )} -
- - {showAdd && canManageMembers && ( -
- - - -
- )} - - {isLoading ? ( -
Loading members…
- ) : ( -
- - - - - - {canManageMembers && - - - {members.map((member) => ( - - - - {canManageMembers && ( - - )} - - ))} - -
- User - - Role - } -
-
{member.user_email}
- {member.user_name && ( -
{member.user_name}
- )} -
- {canManageMembers ? ( - - ) : ( - member.role_name - )} - - -
-
- )} - -

- Org admins can also manage{' '} - - workspace roles - {' '} - under IAM settings. -

-
- ) -} From 88c7ff6707e9c34079f1af751c87e29369296f63 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 12 Jun 2026 11:03:34 +0000 Subject: [PATCH 05/12] feat: updating tests --- app/api/v1/routes/call_import_evaluations.py | 11 ++ app/api/v1/routes/chat.py | 7 +- app/api/v1/routes/evaluators.py | 4 + app/api/v1/routes/prompt_partials.py | 12 +- app/api/v1/routes/voice_playground.py | 23 ++- app/migrations/049_llm_generation_config.py | 49 +++++ app/models/database.py | 2 + app/models/schemas.py | 32 ++++ app/services/ai/llm_generation_config.py | 119 +++++++++++++ app/services/ai/llm_service.py | 18 ++ app/services/testing/test_agent_service.py | 5 +- app/services/voice_agent/voice_bundle.py | 21 ++- app/workers/tasks/evaluate_call_import_row.py | 52 ++++-- app/workers/tasks/helpers/llm_evaluation.py | 5 +- .../src/components/AIProviderModelPicker.tsx | 20 ++- .../providers/LLMAdvancedOptionsPanel.tsx | 134 ++++++++++++++ .../providers/ProviderModelPicker.tsx | 14 ++ .../src/components/shared/AIGeneratePanel.tsx | 18 +- frontend/src/config/llmGenerationParams.ts | 168 ++++++++++++++++++ frontend/src/lib/api.ts | 10 ++ .../pages/callImports/CallImportDetail.tsx | 6 + .../CallImportEvaluationDetail.tsx | 11 +- .../MetricPromptImprovementsPanel.tsx | 4 + .../src/pages/configurations/VoiceBundles.tsx | 69 ++++--- .../evaluators/evaluators/EvaluatorDetail.tsx | 13 ++ .../voice/components/SampleTextsPanel.tsx | 12 ++ .../voice/context/VoicePlaygroundContext.tsx | 4 +- .../pages/promptPartials/PromptPartials.tsx | 4 + frontend/src/pages/scenarios/Scenarios.tsx | 25 ++- frontend/src/types/api.ts | 7 +- tests/test_api/conftest.py | 4 +- .../test_ai/test_llm_generation_config.py | 57 ++++++ 32 files changed, 856 insertions(+), 84 deletions(-) create mode 100644 app/migrations/049_llm_generation_config.py create mode 100644 app/services/ai/llm_generation_config.py create mode 100644 frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx create mode 100644 frontend/src/config/llmGenerationParams.ts create mode 100644 tests/test_services/test_ai/test_llm_generation_config.py diff --git a/app/api/v1/routes/call_import_evaluations.py b/app/api/v1/routes/call_import_evaluations.py index c74222a9..cba06861 100644 --- a/app/api/v1/routes/call_import_evaluations.py +++ b/app/api/v1/routes/call_import_evaluations.py @@ -432,6 +432,9 @@ def _serialize_eval( llm_provider=row.llm_provider, llm_model=row.llm_model, llm_credential_id=row.llm_credential_id, + llm_config=( + row.llm_config if isinstance(getattr(row, "llm_config", None), dict) else None + ), metric_llm_overrides=( row.metric_llm_overrides if isinstance(row.metric_llm_overrides, dict) @@ -717,6 +720,8 @@ async def create_call_import_evaluation( ) if override.credential_id is not None: override_dict["credential_id"] = str(override.credential_id) + if override.llm_config is not None: + override_dict["llm_config"] = override.llm_config if override_dict: for leaf_id in target_leaf_ids: metric_overrides_payload[leaf_id] = override_dict @@ -887,6 +892,7 @@ def _name_for_source(source: str) -> Optional[str]: llm_provider=llm_provider_norm, llm_model=llm_model_norm, llm_credential_id=payload.llm_credential_id, + llm_config=payload.llm_config, metric_llm_overrides=metric_overrides_payload, stt_provider=stt_provider_norm, stt_model=stt_model_norm, @@ -7984,6 +7990,9 @@ def _apply_retry_overrides( ) evaluation.llm_credential_id = payload.llm_credential_id + if payload.llm_config is not None: + evaluation.llm_config = payload.llm_config + # --- Per-metric LLM overrides --- # We accept the same dict shape as the create endpoint but # constrain keys to leaf metrics that are actually in this run. @@ -8036,6 +8045,8 @@ def _apply_retry_overrides( ) if override.credential_id is not None: override_dict["credential_id"] = str(override.credential_id) + if override.llm_config is not None: + override_dict["llm_config"] = override.llm_config if override_dict: overrides_payload[metric_id] = override_dict evaluation.metric_llm_overrides = overrides_payload or None diff --git a/app/api/v1/routes/chat.py b/app/api/v1/routes/chat.py index 31b6184a..b25d731e 100644 --- a/app/api/v1/routes/chat.py +++ b/app/api/v1/routes/chat.py @@ -4,7 +4,7 @@ """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Any from uuid import UUID from pydantic import BaseModel @@ -26,6 +26,7 @@ class ChatRequest(BaseModel): model: str temperature: Optional[float] = 0.7 max_tokens: Optional[int] = None + llm_config: Optional[Dict[str, Any]] = None class ChatResponse(BaseModel): @@ -52,8 +53,10 @@ async def chat_completion( llm_model=request.model, organization_id=organization_id, db=db, + llm_config=request.llm_config, temperature=request.temperature, - max_tokens=request.max_tokens + max_tokens=request.max_tokens, + task_defaults={"temperature": 0.7}, ) return ChatResponse( diff --git a/app/api/v1/routes/evaluators.py b/app/api/v1/routes/evaluators.py index 1f748e4a..610ba16d 100644 --- a/app/api/v1/routes/evaluators.py +++ b/app/api/v1/routes/evaluators.py @@ -228,6 +228,7 @@ def create_evaluator( metric_ids=validated_metric_ids if (is_custom and validated_metric_ids) else None, llm_provider=evaluator_data.llm_provider.value if evaluator_data.llm_provider else None, llm_model=evaluator_data.llm_model, + llm_config=evaluator_data.llm_config, tags=evaluator_data.tags, ) db.add(evaluator) @@ -478,6 +479,9 @@ def update_evaluator( if evaluator_data.llm_model is not None: evaluator.llm_model = evaluator_data.llm_model + if evaluator_data.llm_config is not None: + evaluator.llm_config = evaluator_data.llm_config + db.commit() db.refresh(evaluator) diff --git a/app/api/v1/routes/prompt_partials.py b/app/api/v1/routes/prompt_partials.py index e0372c0a..f1672597 100644 --- a/app/api/v1/routes/prompt_partials.py +++ b/app/api/v1/routes/prompt_partials.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse, Response from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from typing import List, Optional +from typing import List, Optional, Dict, Any from uuid import UUID from pydantic import BaseModel from loguru import logger @@ -40,6 +40,7 @@ class GeneratePromptRequest(BaseModel): format_style: Optional[str] = "structured" provider: Optional[str] = None model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None class ImprovePromptRequest(BaseModel): @@ -47,6 +48,7 @@ class ImprovePromptRequest(BaseModel): instructions: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None class GenerateFlowchartRequest(BaseModel): @@ -213,8 +215,8 @@ async def generate_prompt_with_ai( llm_model=model_str, organization_id=organization_id, db=db, - temperature=0.7, - max_tokens=4000, + llm_config=data.llm_config, + task_defaults={"temperature": 0.7, "max_tokens": 4000}, ) return {"content": result["text"], "provider": provider_enum.value, "model": model_str} except Exception as e: @@ -255,8 +257,8 @@ async def improve_prompt_with_ai( llm_model=model_str, organization_id=organization_id, db=db, - temperature=0.3, - max_tokens=4000, + llm_config=data.llm_config, + task_defaults={"temperature": 0.3, "max_tokens": 4000}, ) return {"content": result["text"], "provider": provider_enum.value, "model": model_str} except Exception as e: diff --git a/app/api/v1/routes/voice_playground.py b/app/api/v1/routes/voice_playground.py index 48cc0f81..3213e6aa 100644 --- a/app/api/v1/routes/voice_playground.py +++ b/app/api/v1/routes/voice_playground.py @@ -287,6 +287,7 @@ class GenerateSamplesRequest(BaseModel): count: int = 5 length: Optional[str] = "short" # "short" | "medium" | "long" | "paragraph" temperature: Optional[float] = 0.8 + llm_config: Optional[Dict[str, Any]] = None class CustomVoiceCreate(BaseModel): @@ -400,7 +401,7 @@ async def generate_sample_texts( llm_provider_str = data.provider llm_model_str = data.model - temperature = data.temperature or 0.8 + request_llm_config = data.llm_config if data.voice_bundle_id: bundle = db.query(VoiceBundle).filter( @@ -413,8 +414,20 @@ async def generate_sample_texts( raise HTTPException(400, "Selected voice bundle has no LLM configured") llm_provider_str = bundle.llm_provider llm_model_str = bundle.llm_model - if bundle.llm_temperature is not None: - temperature = bundle.llm_temperature + from app.services.ai.llm_generation_config import merge_llm_config + + request_llm_config = merge_llm_config( + request_llm_config or bundle.llm_config, + legacy_temperature=bundle.llm_temperature, + legacy_max_tokens=bundle.llm_max_tokens, + ) + if data.temperature is not None and not (request_llm_config or {}).get("temperature"): + request_llm_config = {**(request_llm_config or {}), "temperature": data.temperature} + elif data.temperature is not None: + request_llm_config = { + **(request_llm_config or {}), + "temperature": data.temperature or 0.8, + } if not llm_provider_str or not llm_model_str: raise HTTPException(400, "Either voice_bundle_id or both provider and model are required") @@ -449,8 +462,8 @@ async def generate_sample_texts( llm_model=llm_model_str, organization_id=organization_id, db=db, - temperature=temperature, - max_tokens=max_tokens, + llm_config=request_llm_config, + task_defaults={"temperature": 0.8, "max_tokens": max_tokens}, ) except Exception as e: logger.error(f"[VoicePlayground] LLM generation failed: {e}") diff --git a/app/migrations/049_llm_generation_config.py b/app/migrations/049_llm_generation_config.py new file mode 100644 index 00000000..720736db --- /dev/null +++ b/app/migrations/049_llm_generation_config.py @@ -0,0 +1,49 @@ +"""Migration: Add llm_config JSON to evaluators and call_import_evaluations.""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = ( + "Add llm_config JSON columns for user-tunable LLM generation parameters." +) + + +def _column_exists(db: Session, table_name: str, column_name: str) -> bool: + row = db.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = :table_name AND column_name = :column_name + """ + ), + {"table_name": table_name, "column_name": column_name}, + ).first() + return row is not None + + +def upgrade(db: Session): + if not _column_exists(db, "evaluators", "llm_config"): + db.execute(text("ALTER TABLE evaluators ADD COLUMN llm_config JSON")) + print("Added evaluators.llm_config") + else: + print("evaluators.llm_config already exists, skipping") + + if not _column_exists(db, "call_import_evaluations", "llm_config"): + db.execute( + text("ALTER TABLE call_import_evaluations ADD COLUMN llm_config JSON") + ) + print("Added call_import_evaluations.llm_config") + else: + print("call_import_evaluations.llm_config already exists, skipping") + + +def downgrade(db: Session): + if _column_exists(db, "evaluators", "llm_config"): + db.execute(text("ALTER TABLE evaluators DROP COLUMN llm_config")) + print("Dropped evaluators.llm_config") + if _column_exists(db, "call_import_evaluations", "llm_config"): + db.execute( + text("ALTER TABLE call_import_evaluations DROP COLUMN llm_config") + ) + print("Dropped call_import_evaluations.llm_config") diff --git a/app/models/database.py b/app/models/database.py index ceed0888..a4042f68 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -757,6 +757,7 @@ class Evaluator(Base): # LLM configuration for evaluation (overrides hardcoded defaults) llm_provider = Column(String, nullable=True) # e.g. "openai", "anthropic", "google" llm_model = Column(String, nullable=True) # e.g. "gpt-4.1", "claude-sonnet-4-20250514" + llm_config = Column(JSON, nullable=True) # Tags for categorization tags = Column(JSON, nullable=True) # Array of tag strings @@ -2142,6 +2143,7 @@ class CallImportEvaluation(Base): ForeignKey("aiproviders.id", ondelete="SET NULL"), nullable=True, ) + llm_config = Column(JSON, nullable=True) # Optional per-metric LLM override: # ``{"": {"provider": "...", "model": "...", "credential_id": "..."}}``. # Each entry overrides the run-level default for that metric only; diff --git a/app/models/schemas.py b/app/models/schemas.py index c5025abb..eaf315c2 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -719,6 +719,22 @@ def convert_provider(cls, v): model_config = ConfigDict(from_attributes=True) +class LLMGenerationConfig(BaseModel): + """User-tunable LLM sampling / generation parameters.""" + + temperature: Optional[float] = Field(None, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(None, gt=0) + top_p: Optional[float] = Field(None, ge=0.0, le=1.0) + top_k: Optional[int] = Field(None, ge=0) + frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) + presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0) + seed: Optional[int] = Field(None, ge=0) + + def to_dict(self) -> Dict[str, Any]: + """Return only explicitly set fields.""" + return self.model_dump(exclude_none=True) + + # VoiceBundle Schemas class VoiceBundleCreate(BaseModel): """Schema for creating a VoiceBundle.""" @@ -1002,6 +1018,7 @@ class EvaluatorCreate(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] = None @@ -1015,6 +1032,7 @@ class EvaluatorUpdate(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] = None @@ -1031,6 +1049,7 @@ class EvaluatorResponse(BaseModel): metric_ids: Optional[List[UUID]] = None llm_provider: Optional[ModelProvider] = None llm_model: Optional[str] = None + llm_config: Optional[Dict[str, Any]] = None tags: Optional[List[str]] created_at: datetime updated_at: datetime @@ -2591,6 +2610,10 @@ class CallImportEvaluationLLMOverride(BaseModel): default=None, description="Optional AIProvider id when the org has multiple credentials.", ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional per-metric generation parameters (temperature, top_p, etc.).", + ) CallImportEvaluationTranscriptSource = Literal["production", "diarised"] @@ -2662,6 +2685,10 @@ def _validate_transcript_sources( default=None, description="Optional AIProvider row to pin for the run-level LLM.", ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Run-level LLM generation parameters (temperature, top_p, etc.).", + ) metric_llm_overrides: Optional[ Dict[str, CallImportEvaluationLLMOverride] ] = Field( @@ -2876,6 +2903,10 @@ class CallImportEvaluationRetryRequest(BaseModel): "When omitted, the resolver falls back to the org default." ), ) + llm_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Override run-level LLM generation parameters for this retry.", + ) metric_llm_overrides: Optional[ Dict[str, CallImportEvaluationLLMOverride] ] = Field( @@ -3023,6 +3054,7 @@ class CallImportEvaluationResponse(BaseModel): llm_provider: Optional[str] = None llm_model: Optional[str] = None llm_credential_id: Optional[UUID] = None + llm_config: Optional[Dict[str, Any]] = None metric_llm_overrides: Optional[Dict[str, Any]] = None stt_provider: Optional[str] = None stt_model: Optional[str] = None diff --git a/app/services/ai/llm_generation_config.py b/app/services/ai/llm_generation_config.py new file mode 100644 index 00000000..5b94580e --- /dev/null +++ b/app/services/ai/llm_generation_config.py @@ -0,0 +1,119 @@ +"""Helpers for merging and applying user LLM generation parameters.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def merge_llm_config( + primary: Optional[Dict[str, Any]] = None, + *, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, +) -> Dict[str, Any]: + """Merge ``llm_config`` JSON with legacy VoiceBundle columns. + + Legacy ``llm_temperature`` / ``llm_max_tokens`` apply only when the + corresponding key is absent from ``primary``. + """ + merged: Dict[str, Any] = dict(primary or {}) + if merged.get("temperature") is None and legacy_temperature is not None: + merged["temperature"] = legacy_temperature + if merged.get("max_tokens") is None and legacy_max_tokens is not None: + merged["max_tokens"] = legacy_max_tokens + return merged + + +def resolve_effective_llm_config( + *, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, + task_defaults: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Resolve config with override > entity > legacy columns > task defaults.""" + base = merge_llm_config( + llm_config, + legacy_temperature=legacy_temperature, + legacy_max_tokens=legacy_max_tokens, + ) + if override_llm_config: + for key, value in override_llm_config.items(): + if value is not None: + base[key] = value + + effective = dict(task_defaults or {}) + for key, value in base.items(): + if value is not None: + effective[key] = value + return effective + + +def build_litellm_kwargs( + *, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + legacy_temperature: Optional[float] = None, + legacy_max_tokens: Optional[int] = None, + task_defaults: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build kwargs for :meth:`LLMService.generate_response`. + + Returns a dict with ``temperature``, ``max_tokens``, and ``config`` + (extra LiteLLM params such as top_p / top_k / penalties). + """ + effective = resolve_effective_llm_config( + llm_config=llm_config, + override_llm_config=override_llm_config, + legacy_temperature=legacy_temperature, + legacy_max_tokens=legacy_max_tokens, + task_defaults=task_defaults, + ) + + temperature = effective.pop("temperature", (task_defaults or {}).get("temperature", 0.7)) + max_tokens = effective.pop("max_tokens", (task_defaults or {}).get("max_tokens")) + extra = {k: v for k, v in effective.items() if v is not None} + return { + "temperature": temperature, + "max_tokens": max_tokens, + "config": extra or None, + } + + +def build_efficientai_input_params(provider: str, config: Optional[Dict[str, Any]]): + """Map merged config dict to the EfficientAI SDK InputParams for *provider*.""" + if not config: + return None + + provider_key = (provider or "").lower() + params_dict = { + k: v + for k, v in { + "temperature": config.get("temperature"), + "top_p": config.get("top_p"), + "top_k": config.get("top_k"), + "max_tokens": config.get("max_tokens"), + "frequency_penalty": config.get("frequency_penalty"), + "presence_penalty": config.get("presence_penalty"), + "seed": config.get("seed"), + }.items() + if v is not None + } + if not params_dict: + return None + + if provider_key == "openai": + from efficientai.services.openai.llm import OpenAILLMService + + return OpenAILLMService.InputParams(**params_dict) + if provider_key == "google": + from efficientai.services.google.llm import GoogleLLMService + + return GoogleLLMService.InputParams(**params_dict) + if provider_key == "anthropic": + from efficientai.services.anthropic.llm import AnthropicLLMService + + return AnthropicLLMService.InputParams(**params_dict) + + return None diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index 701a6862..407ee51d 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -19,6 +19,7 @@ from app.models.database import ModelProvider, AIProvider from app.services.credentials import resolve_ai_provider +from app.services.ai.llm_generation_config import build_litellm_kwargs # LiteLLM will silently drop params the target provider doesn't support # rather than raising an error. @@ -178,6 +179,9 @@ def generate_response( temperature: Optional[float] = 0.7, max_tokens: Optional[int] = None, config: Optional[Dict[str, Any]] = None, + llm_config: Optional[Dict[str, Any]] = None, + override_llm_config: Optional[Dict[str, Any]] = None, + task_defaults: Optional[Dict[str, Any]] = None, credential_id: Optional[UUID] = None, ) -> Dict[str, Any]: """Generate a text response using the specified LLM via LiteLLM. @@ -186,7 +190,21 @@ def generate_response( organization has multiple keys for the same provider; when omitted the resolver falls back to the row marked ``is_default`` (or the most recently updated active row for back-compat). + + Generation parameters resolve as: + override_llm_config > llm_config/config > legacy temperature/max_tokens > task_defaults. """ + gen_kwargs = build_litellm_kwargs( + llm_config=llm_config or config, + override_llm_config=override_llm_config, + legacy_temperature=temperature, + legacy_max_tokens=max_tokens, + task_defaults=task_defaults, + ) + temperature = gen_kwargs["temperature"] + max_tokens = gen_kwargs["max_tokens"] + config = gen_kwargs["config"] + start_time = time.time() # --- resolve API key from database -------------------------------- diff --git a/app/services/testing/test_agent_service.py b/app/services/testing/test_agent_service.py index 6580f661..0719bbb7 100644 --- a/app/services/testing/test_agent_service.py +++ b/app/services/testing/test_agent_service.py @@ -348,9 +348,10 @@ def process_audio_chunk( llm_model=voice_bundle.llm_model, organization_id=organization_id, db=db, - temperature=voice_bundle.llm_temperature or 0.7, + llm_config=voice_bundle.llm_config, + temperature=voice_bundle.llm_temperature, max_tokens=voice_bundle.llm_max_tokens, - config=voice_bundle.llm_config, + task_defaults={"temperature": 0.7}, ) test_agent_text = llm_result.get("text", "").strip() diff --git a/app/services/voice_agent/voice_bundle.py b/app/services/voice_agent/voice_bundle.py index abce7a42..b1c4546f 100644 --- a/app/services/voice_agent/voice_bundle.py +++ b/app/services/voice_agent/voice_bundle.py @@ -320,17 +320,19 @@ def _get_llm_providers(): "openai": { "env_key": "OPENAI_API_KEY", "default_model": "gpt-4.1", - "factory": lambda api_key, model: _get_service("OpenAILLMService")( + "factory": lambda api_key, model, params=None: _get_service("OpenAILLMService")( api_key=api_key, model=model, + **({"params": params} if params else {}), ), }, "google": { "env_key": "GOOGLE_API_KEY", "default_model": "gemini-2.5-flash", - "factory": lambda api_key, model: _get_service("GoogleLLMService")( + "factory": lambda api_key, model, params=None: _get_service("GoogleLLMService")( api_key=api_key, model=model, + **({"params": params} if params else {}), ), }, } @@ -556,7 +558,20 @@ async def run_voice_bundle_fastapi( tts = tts_cfg["factory"](api_key=tts_api_key, voice_id=tts_voice_id, model=tts_model) llm_model = getattr(voice_bundle, "llm_model", None) or llm_cfg["default_model"] - llm = llm_cfg["factory"](api_key=llm_api_key, model=llm_model) + from app.services.ai.llm_generation_config import ( + build_efficientai_input_params, + merge_llm_config, + ) + + llm_params = build_efficientai_input_params( + llm_provider_value, + merge_llm_config( + getattr(voice_bundle, "llm_config", None), + legacy_temperature=getattr(voice_bundle, "llm_temperature", None), + legacy_max_tokens=getattr(voice_bundle, "llm_max_tokens", None), + ), + ) + llm = llm_cfg["factory"](api_key=llm_api_key, model=llm_model, params=llm_params) # Build context with provided system instruction or a default base_instruction = ( diff --git a/app/workers/tasks/evaluate_call_import_row.py b/app/workers/tasks/evaluate_call_import_row.py index fae2d13e..6653e48a 100644 --- a/app/workers/tasks/evaluate_call_import_row.py +++ b/app/workers/tasks/evaluate_call_import_row.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json from datetime import datetime, timezone from types import SimpleNamespace from typing import Any, List, Optional @@ -700,6 +701,11 @@ def _load_ai_providers() -> list: if comparison_metrics: run_provider = (evaluation.llm_provider or "").strip() or None run_model = (evaluation.llm_model or "").strip() or None + run_llm_config = ( + evaluation.llm_config + if isinstance(getattr(evaluation, "llm_config", None), dict) + else None + ) overrides = ( evaluation.metric_llm_overrides if isinstance(evaluation.metric_llm_overrides, dict) @@ -709,11 +715,13 @@ def _load_ai_providers() -> list: override = overrides.get(str(cmp_metric.id)) or {} provider = override.get("provider") or run_provider or None model = override.get("model") or run_model or None + llm_config = override.get("llm_config") or run_llm_config evaluator_obj = None if provider and model: evaluator_obj = SimpleNamespace( llm_provider=provider, llm_model=model, + llm_config=llm_config, custom_prompt=None, ) try: @@ -774,37 +782,47 @@ def _load_ai_providers() -> list: # ``evaluate_with_llm``. run_provider = (evaluation.llm_provider or "").strip() or None run_model = (evaluation.llm_model or "").strip() or None + run_llm_config = ( + evaluation.llm_config + if isinstance(getattr(evaluation, "llm_config", None), dict) + else None + ) overrides = ( evaluation.metric_llm_overrides if isinstance(evaluation.metric_llm_overrides, dict) else {} ) - def _resolve_pm(metric: Metric) -> tuple[str | None, str | None]: + def _llm_config_key(cfg: dict | None) -> str | None: + if not cfg: + return None + return json.dumps(cfg, sort_keys=True, default=str) + + def _resolve_pm( + metric: Metric, + ) -> tuple[str | None, str | None, dict | None]: override = overrides.get(str(metric.id)) or {} provider = ( override.get("provider") or run_provider or None ) model = override.get("model") or run_model or None - return provider, model + llm_config = override.get("llm_config") or run_llm_config + return provider, model, llm_config - # Bucket = ((provider, model), parent_id_or_None) -> metrics. - # parent_id_or_None keys hierarchical groups; ``None`` keys - # the standalone bucket. Splitting on parent_id lets us pass - # ``parent_metric`` to ``evaluate_with_llm`` for prompt rendering. - BucketKey = tuple[tuple[str | None, str | None], UUID | None] + # Bucket = ((provider, model, llm_config_key), parent_id_or_None) -> metrics. + BucketKey = tuple[tuple[str | None, str | None, str | None], UUID | None] groups: dict[BucketKey, list[Metric]] = {} for metric in standalone_metrics: - provider, model = _resolve_pm(metric) - groups.setdefault(((provider, model), None), []).append(metric) + provider, model, llm_config = _resolve_pm(metric) + groups.setdefault( + ((provider, model, _llm_config_key(llm_config)), None), + [], + ).append(metric) for parent_id, children in children_by_parent.items(): - # Children of the same parent MUST end up in one bucket - # (no per-child provider/model split inside a hierarchy - # group) — otherwise we lose the mutex / consistency - # guarantees. Use the first child's resolved config. - provider, model = _resolve_pm(children[0]) + provider, model, llm_config = _resolve_pm(children[0]) groups.setdefault( - ((provider, model), parent_id), [] + ((provider, model, _llm_config_key(llm_config)), parent_id), + [], ).extend(children) # Top-level metric discovery is opt-in per evaluation. When @@ -842,12 +860,14 @@ def _resolve_pm(metric: Metric) -> tuple[str | None, str | None]: metric_discovery_emitted = False for (config, parent_id), bucket in groups.items(): - provider, model = config + provider, model, llm_config_key = config + llm_config = json.loads(llm_config_key) if llm_config_key else None evaluator_obj = None if provider and model: evaluator_obj = SimpleNamespace( llm_provider=provider, llm_model=model, + llm_config=llm_config, custom_prompt=None, ) parent_metric = ( diff --git a/app/workers/tasks/helpers/llm_evaluation.py b/app/workers/tasks/helpers/llm_evaluation.py index 5fe3e92c..01d13fc3 100644 --- a/app/workers/tasks/helpers/llm_evaluation.py +++ b/app/workers/tasks/helpers/llm_evaluation.py @@ -1192,14 +1192,15 @@ def evaluate_with_llm( ) evaluation_start_time = time.time() + evaluator_llm_config = getattr(evaluator, "llm_config", None) if evaluator else None llm_result = llm_service.generate_response( messages=messages, llm_provider=llm_provider, llm_model=llm_model, organization_id=organization_id, db=db, - temperature=0.3, - max_tokens=dynamic_max_tokens, + llm_config=evaluator_llm_config, + task_defaults={"temperature": 0.3, "max_tokens": dynamic_max_tokens}, ) evaluation_time = time.time() - evaluation_start_time diff --git a/frontend/src/components/AIProviderModelPicker.tsx b/frontend/src/components/AIProviderModelPicker.tsx index c19ac2b0..37d6439d 100644 --- a/frontend/src/components/AIProviderModelPicker.tsx +++ b/frontend/src/components/AIProviderModelPicker.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { Bot } from 'lucide-react' import { apiClient } from '../lib/api' +import LLMAdvancedOptionsPanel from './providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../config/llmGenerationParams' interface AIProviderRow { id: string @@ -37,15 +39,21 @@ export default function AIProviderModelPicker({ model, onProviderChange, onModelChange, + llm_config, + onLLMConfigChange, disabled = false, size = 'md', + showAdvancedOptions = true, }: { provider: string model: string onProviderChange: (next: string) => void onModelChange: (next: string) => void + llm_config?: LLMGenerationConfig | null + onLLMConfigChange?: (next: LLMGenerationConfig | null) => void disabled?: boolean size?: 'sm' | 'md' + showAdvancedOptions?: boolean }) { const { data: aiProviders = [] } = useQuery({ queryKey: ['ai-providers'], @@ -82,7 +90,8 @@ export default function AIProviderModelPicker({ : 'block text-xs font-medium text-gray-600 mb-1' return ( -
+
+
+
+ {showAdvancedOptions && provider && onLLMConfigChange && ( + + )}
) } diff --git a/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx b/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx new file mode 100644 index 00000000..02c51985 --- /dev/null +++ b/frontend/src/components/providers/LLMAdvancedOptionsPanel.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { ChevronDown, ChevronUp } from 'lucide-react' +import { + getVisibleLLMParams, + isLLMGenerationConfigEmpty, + normalizeLLMConfig, + summarizeLLMConfig, + type LLMGenerationConfig, + type LLMGenerationParamKey, +} from '../../config/llmGenerationParams' + +interface LLMAdvancedOptionsPanelProps { + provider: string | null | undefined + value: LLMGenerationConfig | null | undefined + onChange: (next: LLMGenerationConfig | null) => void + disabled?: boolean + /** When true, show Gemini thinking note. */ + showGeminiNote?: boolean + className?: string +} + +export default function LLMAdvancedOptionsPanel({ + provider, + value, + onChange, + disabled = false, + showGeminiNote = true, + className, +}: LLMAdvancedOptionsPanelProps) { + const [showAdvanced, setShowAdvanced] = useState(false) + const params = getVisibleLLMParams(provider) + const summary = summarizeLLMConfig(value) + const providerKey = (provider || '').toLowerCase() + + const handleFieldChange = (key: LLMGenerationParamKey, raw: string) => { + const next: LLMGenerationConfig = { ...(value || {}) } + if (raw === '' || raw === undefined) { + delete next[key] + } else { + const meta = params.find((p) => p.key === key) + next[key] = meta?.integer ? parseInt(raw, 10) : parseFloat(raw) + } + onChange(normalizeLLMConfig(next)) + } + + const handleReset = () => { + onChange(null) + } + + if (!provider) { + return null + } + + return ( +
+ + {showAdvanced && ( +
+ {showGeminiNote && providerKey === 'google' && ( +

+ Gemini thinking level is managed automatically for structured evaluation tasks. +

+ )} +
+ {params.map((param) => ( +
+ + handleFieldChange(param.key, e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white disabled:bg-gray-50" + /> + {param.helpText && ( +

{param.helpText}

+ )} +
+ ))} +
+ {!isLLMGenerationConfigEmpty(value) && ( + + )} +
+ )} +
+ ) +} + +export type { LLMGenerationConfig } diff --git a/frontend/src/components/providers/ProviderModelPicker.tsx b/frontend/src/components/providers/ProviderModelPicker.tsx index 1ab75664..c2f26032 100644 --- a/frontend/src/components/providers/ProviderModelPicker.tsx +++ b/frontend/src/components/providers/ProviderModelPicker.tsx @@ -29,6 +29,8 @@ import { Bot, AudioLines } from 'lucide-react' import { apiClient } from '../../lib/api' import type { Integration } from '../../types/api' +import LLMAdvancedOptionsPanel from './LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../config/llmGenerationParams' const PROVIDER_LABELS: Record = { openai: 'OpenAI', @@ -54,6 +56,7 @@ export interface ProviderModelValue { provider: string | null model: string | null credential_id?: string | null + llm_config?: LLMGenerationConfig | null } interface AIProviderRow { @@ -107,6 +110,8 @@ export interface ProviderModelPickerProps { * without a code change. */ audioCapableOnly?: boolean + /** When false, hide the LLM advanced options panel (LLM kind only). */ + showAdvancedOptions?: boolean } // Per-provider substring fingerprints for "this chat model accepts audio @@ -142,6 +147,7 @@ export default function ProviderModelPicker({ allowCredentialPick = false, disabled = false, audioCapableOnly = false, + showAdvancedOptions = true, }: ProviderModelPickerProps) { const { data: aiProviders = [] } = useQuery({ queryKey: ['ai-providers'], @@ -354,6 +360,14 @@ export default function ProviderModelPicker({
)} + {kind === 'llm' && showAdvancedOptions && value.provider && ( + onChange({ ...value, llm_config })} + /> + )}
) } diff --git a/frontend/src/components/shared/AIGeneratePanel.tsx b/frontend/src/components/shared/AIGeneratePanel.tsx index 739a1503..8a1a1812 100644 --- a/frontend/src/components/shared/AIGeneratePanel.tsx +++ b/frontend/src/components/shared/AIGeneratePanel.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect, useRef } from 'react' -import { useQuery, useMutation } from '@tanstack/react-query' +import { useState, useRef, useEffect } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' import { Sparkles, Loader2, Bot, ChevronDown } from 'lucide-react' import { apiClient } from '../../lib/api' import { getProviderLabel, getProviderLogo } from '../../config/providers' import { ModelProvider } from '../../types/api' +import LLMAdvancedOptionsPanel from '../providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../config/llmGenerationParams' interface AIProvider { id: string @@ -29,6 +31,7 @@ interface AIGeneratePanelProps { format_style?: string provider?: string model?: string + llm_config?: LLMGenerationConfig | null }) => Promise<{ content: string }> title?: string placeholder?: string @@ -48,6 +51,7 @@ export default function AIGeneratePanel({ const [format, setFormat] = useState('structured') const [provider, setProvider] = useState('') const [model, setModel] = useState('') + const [llmConfig, setLlmConfig] = useState(null) const [showProviderDropdown, setShowProviderDropdown] = useState(false) const providerDropdownRef = useRef(null) @@ -100,6 +104,7 @@ export default function AIGeneratePanel({ format_style: showToneAndFormat ? format : undefined, ...(provider ? { provider } : {}), ...(model ? { model } : {}), + ...(llmConfig ? { llm_config: llmConfig } : {}), }) } @@ -242,6 +247,15 @@ export default function AIGeneratePanel({
+ + {provider && ( + + )}
@@ -1104,33 +1118,18 @@ function VoiceBundleModal({ )}
-
- - setFormData({ ...formData, llm_temperature: parseFloat(e.target.value) || 0.7 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - /> -
-
- - setFormData({ ...formData, llm_max_tokens: e.target.value ? parseInt(e.target.value) : null })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="Leave empty for default" +
+ + setFormData({ + ...formData, + llm_config, + llm_temperature: null, + llm_max_tokens: null, + }) + } />
{renderCredentialPicker( diff --git a/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx b/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx index a5c76d71..2530434d 100644 --- a/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx +++ b/frontend/src/pages/evaluators/evaluators/EvaluatorDetail.tsx @@ -8,6 +8,8 @@ import Button from '../../../components/Button' import { ArrowLeft, Edit, Save, X, Phone, Globe, Trash2, AlertCircle, Brain, ChevronDown } from 'lucide-react' import { useToast } from '../../../hooks/useToast' import { getProviderLabel, getProviderLogo } from '../../../config/providers' +import LLMAdvancedOptionsPanel from '../../../components/providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../../config/llmGenerationParams' interface Evaluator { id: string @@ -20,6 +22,7 @@ interface Evaluator { metric_ids?: string[] | null llm_provider?: string | null llm_model?: string | null + llm_config?: LLMGenerationConfig | null tags?: string[] created_at: string updated_at: string @@ -279,6 +282,7 @@ export default function EvaluatorDetail() { } if (editData.llm_provider) data.llm_provider = editData.llm_provider if (editData.llm_model) data.llm_model = editData.llm_model + if (editData.llm_config !== undefined) data.llm_config = editData.llm_config updateMutation.mutate({ evalId: evaluator.id, data }) } @@ -879,6 +883,15 @@ export default function EvaluatorDetail() { )}
+
+ + setEditData({ ...editData, llm_config }) + } + /> +
)}
diff --git a/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx b/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx index 42f95f54..5af6d921 100644 --- a/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx +++ b/frontend/src/pages/playground/voice/components/SampleTextsPanel.tsx @@ -8,6 +8,8 @@ import { useToast } from '../../../../hooks/useToast' import { AIProvider } from '../../../../types/api' import { useVoicePlayground } from '../context' import { DEFAULT_SAMPLE_TEXTS } from '../types' +import LLMAdvancedOptionsPanel from '../../../../components/providers/LLMAdvancedOptionsPanel' +import type { LLMGenerationConfig } from '../../../../config/llmGenerationParams' interface PromptPartial { id: string @@ -58,6 +60,7 @@ export default function SampleTextsPanel() { const [selectedLlmProvider, setSelectedLlmProvider] = useState('') const [selectedLlmModel, setSelectedLlmModel] = useState('') + const [sampleLlmConfig, setSampleLlmConfig] = useState(null) const { showToast, ToastContainer } = useToast() const { data: aiProviders = [] } = useQuery({ @@ -528,6 +531,14 @@ export default function SampleTextsPanel() {
+ {selectedLlmProvider && ( + + )} +
+ {selectedAIProvider && ( +
+ +
+ )}
@@ -1285,6 +1297,15 @@ export default function Scenarios() { ))}
+ {selectedAIProvider && ( +
+ +
+ )}
+ {member.user_name && ( +
+ {member.user_name} +
+ )} + + + {canManageMembers && !isSelfAdmin ? ( + + ) : ( + member.role_name + )} - )} - - )) + {canManageMembers && ( + + + + )} + + ) + }) )} diff --git a/frontend/src/pages/callImports/CallImportDetail.tsx b/frontend/src/pages/callImports/CallImportDetail.tsx index 7c3fb670..75366da0 100644 --- a/frontend/src/pages/callImports/CallImportDetail.tsx +++ b/frontend/src/pages/callImports/CallImportDetail.tsx @@ -32,6 +32,8 @@ import { XCircle, } from 'lucide-react' import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' +import { useToast } from '../../hooks/useToast' import { formatDiarisationError } from '../../lib/diarisationErrors' import { useWorkspaceStore } from '../../store/workspaceStore' import type { @@ -246,6 +248,7 @@ export default function CallImportDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) const [rowOffset, setRowOffset] = useState(0) const [expandedRowIds, setExpandedRowIds] = useState>(new Set()) @@ -344,6 +347,16 @@ export default function CallImportDetail() { null, ) + const reportDeleteError = ( + err: unknown, + fallback: string, + setError: (message: string) => void, + ) => { + const message = getApiErrorMessage(err, fallback) + setError(message) + showToast(message, 'error') + } + const [playingRowId, setPlayingRowId] = useState(null) const [audioUrl, setAudioUrl] = useState(null) const [isPlaying, setIsPlaying] = useState(false) @@ -540,10 +553,8 @@ export default function CallImportDetail() { queryClient.invalidateQueries({ queryKey: ['call-imports'] }) navigate('/call-imports') }, - onError: (err: any) => { - setDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete import.', - ) + onError: (err: unknown) => { + reportDeleteError(err, 'Failed to delete import.', setDeleteError) }, }) @@ -606,10 +617,8 @@ export default function CallImportDetail() { setPendingDeleteRow(null) setDeleteError(null) }, - onError: (err: any) => { - setDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete row.', - ) + onError: (err: unknown) => { + reportDeleteError(err, 'Failed to delete row.', setDeleteError) }, }) @@ -623,11 +632,11 @@ export default function CallImportDetail() { setShowBulkDeleteRows(false) setBulkDeleteRowsError(null) }, - onError: (err: any) => { - setBulkDeleteRowsError( - err?.response?.data?.detail || - err?.message || - 'Failed to delete selected rows.', + onError: (err: unknown) => { + reportDeleteError( + err, + 'Failed to delete selected rows.', + setBulkDeleteRowsError, ) }, }) @@ -1105,11 +1114,11 @@ export default function CallImportDetail() { setShowBulkDeleteEvals(false) setBulkDeleteEvalsError(null) }, - onError: (err: any) => { - setBulkDeleteEvalsError( - err?.response?.data?.detail || - err?.message || - 'Failed to delete evaluation runs.', + onError: (err: unknown) => { + reportDeleteError( + err, + 'Failed to delete evaluation runs.', + setBulkDeleteEvalsError, ) }, }) @@ -1436,6 +1445,7 @@ export default function CallImportDetail() { return (
+
() const navigate = useNavigate() const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() const [searchParams] = useSearchParams() const deepLinkConversationId = searchParams.get('conversation_id')?.trim() || '' @@ -928,10 +931,10 @@ export default function CallImportEvaluationDetail() { setPendingDeleteRow(null) setRowDeleteError(null) }, - onError: (err: any) => { - setRowDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete row.', - ) + onError: (err: unknown) => { + const message = getApiErrorMessage(err, 'Failed to delete row.') + setRowDeleteError(message) + showToast(message, 'error') }, }) @@ -943,6 +946,9 @@ export default function CallImportEvaluationDetail() { }) navigate(`/call-imports/${id}`) }, + onError: (err: unknown) => { + showToast(getApiErrorMessage(err, 'Failed to delete evaluation run.'), 'error') + }, }) // Re-enqueue every failed row in this run. The backend filters to @@ -2075,6 +2081,7 @@ export default function CallImportEvaluationDetail() { return (
+
= [ +const STATUS_OPTIONS: Array<{ label: string; value: '' | CallImportStatus }> = [ { label: 'All statuses', value: '' }, { label: 'Uploaded', value: 'uploaded' }, { label: 'Mapped', value: 'mapped' }, @@ -33,25 +35,26 @@ const STATUS_OPTIONS: Array<{ label: string; value: '' | CallImportStatus }> = [ { label: 'Processing', value: 'processing' }, { label: 'Completed', value: 'completed' }, { label: 'Partial', value: 'partial' }, - { label: 'Failed', value: 'failed' }, -] - -type UploadTab = 'datasets' | 'audio' + { label: 'Failed', value: 'failed' }, +] + +type UploadTab = 'datasets' | 'audio' export default function CallImports() { const navigate = useNavigate() const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() // Active workspace is part of every workspace-scoped queryKey so a // workspace switch produces a clean cache miss instead of leaking // rows from the previously-active workspace. const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) const [page, setPage] = useState(1) - const [statusFilter, setStatusFilter] = useState<'' | CallImportStatus>('') - const [datasetFilter, setDatasetFilter] = useState('') - const [tagFilter, setTagFilter] = useState([]) - const [activeTab, setActiveTab] = useState('datasets') - const [showUpload, setShowUpload] = useState(false) - const [showAudioUpload, setShowAudioUpload] = useState(false) + const [statusFilter, setStatusFilter] = useState<'' | CallImportStatus>('') + const [datasetFilter, setDatasetFilter] = useState('') + const [tagFilter, setTagFilter] = useState([]) + const [activeTab, setActiveTab] = useState('datasets') + const [showUpload, setShowUpload] = useState(false) + const [showAudioUpload, setShowAudioUpload] = useState(false) const [pendingDelete, setPendingDelete] = useState(null) const [deleteError, setDeleteError] = useState(null) @@ -72,10 +75,10 @@ export default function CallImports() { setPendingDelete(null) setDeleteError(null) }, - onError: (err: any) => { - setDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete import.', - ) + onError: (err: unknown) => { + const message = getApiErrorMessage(err, 'Failed to delete import.') + setDeleteError(message) + showToast(message, 'error') }, }) @@ -83,13 +86,13 @@ export default function CallImports() { () => ({ page, page_size: PAGE_SIZE, - ...(statusFilter ? { status: statusFilter } : {}), - ...(datasetFilter ? { dataset: datasetFilter } : {}), - ...(tagFilter.length > 0 ? { tag_id: tagFilter } : {}), - source_format: activeTab === 'audio' ? 'audio' : '__non_audio__', - }), - [page, statusFilter, datasetFilter, tagFilter, activeTab], - ) + ...(statusFilter ? { status: statusFilter } : {}), + ...(datasetFilter ? { dataset: datasetFilter } : {}), + ...(tagFilter.length > 0 ? { tag_id: tagFilter } : {}), + source_format: activeTab === 'audio' ? 'audio' : '__non_audio__', + }), + [page, statusFilter, datasetFilter, tagFilter, activeTab], + ) const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: ['call-imports', activeWorkspaceId, queryParams], @@ -109,13 +112,14 @@ export default function CallImports() { return (
+
-

Call Imports

-

- Upload datasets from CSV / Excel, or add manual call recordings - directly and use the same diarisation and evaluation tools. -

+

Call Imports

+

+ Upload datasets from CSV / Excel, or add manual call recordings + directly and use the same diarisation and evaluation tools. +

@@ -133,16 +137,16 @@ export default function CallImports() { > Manage Tags - - + + -
-
- -
- - -
- - {/* +
+
+ +
+ + +
+ + {/* High-level dataset segregation lives at the top of the page so users can scope all filtering/searching that follows to a specific dataset. We intentionally render this above the main card to make it visually @@ -289,11 +293,11 @@ export default function CallImports() { )}
)} -
-

- {total} {activeTab === 'audio' ? 'manual upload' : 'dataset import'} - {total === 1 ? '' : 's'} -

+
+

+ {total} {activeTab === 'audio' ? 'manual upload' : 'dataset import'} + {total === 1 ? '' : 's'} +

{isLoading ? ( @@ -303,30 +307,30 @@ export default function CallImports() { ) : items.length === 0 ? (
- -

- {statusFilter - ? 'No imports match this filter.' - : activeTab === 'audio' - ? 'No manual audio uploads yet.' - : 'No dataset uploads yet.'} -

- {!statusFilter && ( - - )} + +

+ {statusFilter + ? 'No imports match this filter.' + : activeTab === 'audio' + ? 'No manual audio uploads yet.' + : 'No dataset uploads yet.'} +

+ {!statusFilter && ( + + )}
) : (
@@ -372,12 +376,12 @@ export default function CallImports() {
- {item.source_format === 'audio' ? ( - - - Manual upload - - ) : item.provider || ( + {item.source_format === 'audio' ? ( + + + Manual upload + + ) : item.provider || ( @@ -488,14 +492,14 @@ export default function CallImports() { )} - - setShowUpload(false)} /> - setShowAudioUpload(false)} - /> - - setShowUpload(false)} /> + setShowAudioUpload(false)} + /> + + { diff --git a/frontend/src/pages/callImports/Schemas.tsx b/frontend/src/pages/callImports/Schemas.tsx index 6cb1b8ef..d503ca1d 100644 --- a/frontend/src/pages/callImports/Schemas.tsx +++ b/frontend/src/pages/callImports/Schemas.tsx @@ -13,6 +13,8 @@ import { X, } from 'lucide-react' import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' +import { useToast } from '../../hooks/useToast' import type { CallImportSchema, CallImportSchemaParameter, @@ -625,6 +627,7 @@ function DeleteSchemaModal({ export default function CallImportSchemasPage() { const queryClient = useQueryClient() + const { showToast, ToastContainer } = useToast() const [editorOpen, setEditorOpen] = useState(false) const [editingSchema, setEditingSchema] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) @@ -650,10 +653,10 @@ export default function CallImportSchemasPage() { setPendingDelete(null) setDeleteError(null) }, - onError: (err: any) => { - setDeleteError( - err?.response?.data?.detail || err?.message || 'Failed to delete schema.', - ) + onError: (err: unknown) => { + const message = getApiErrorMessage(err, 'Failed to delete schema.') + setDeleteError(message) + showToast(message, 'error') }, }) @@ -665,6 +668,7 @@ export default function CallImportSchemasPage() { return (
+
{ + showToast(getApiErrorMessage(err, 'Failed to delete tag.'), 'error') + }, }) return (
+
{ + showToast(getApiErrorMessage(error, 'Failed to send invitation'), 'error') }, }) diff --git a/tests/test_api/test_workspace_iam.py b/tests/test_api/test_workspace_iam.py index 5c9f60f6..5fab8b69 100644 --- a/tests/test_api/test_workspace_iam.py +++ b/tests/test_api/test_workspace_iam.py @@ -3,6 +3,7 @@ from __future__ import annotations from uuid import uuid4 +from unittest.mock import patch import pytest from fastapi import FastAPI @@ -13,6 +14,7 @@ METRICS_VIEW, SYSTEM_ROLE_ADMIN, SYSTEM_ROLE_VIEWER, + WORKSPACE_MEMBERS_MANAGE, ) from app.core.auth.principal import AuthMethod, Principal from app.core.auth.dependency import get_principal @@ -47,7 +49,8 @@ def rbac_org(db_session): def rbac_users(db_session, rbac_org): admin = User(id=uuid4(), email="admin@test.local", name="Admin") viewer = User(id=uuid4(), email="viewer@test.local", name="Viewer") - db_session.add_all([admin, viewer]) + writer = User(id=uuid4(), email="writer@test.local", name="Writer") + db_session.add_all([admin, viewer, writer]) db_session.add_all( [ OrganizationMember( @@ -60,10 +63,15 @@ def rbac_users(db_session, rbac_org): user_id=viewer.id, role=RoleEnum.READER, ), + OrganizationMember( + organization_id=rbac_org.id, + user_id=writer.id, + role=RoleEnum.WRITER, + ), ] ) db_session.commit() - return {"admin": admin, "viewer": viewer} + return {"admin": admin, "viewer": viewer, "writer": writer} @pytest.fixture @@ -214,3 +222,320 @@ def _override_db(): with TestClient(app) as client: allowed = client.get(f"/api/v1/workspaces/{ws_b.id}/members") assert allowed.status_code == 200 + + +def _iam_test_app(db_session, principal_factory): + app = FastAPI() + app.include_router(workspace_iam.router, prefix="/api/v1") + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = principal_factory + return app + + +def test_list_capabilities_requires_auth(): + app = FastAPI() + app.include_router(workspace_iam.router, prefix="/api/v1") + + with TestClient(app) as client: + response = client.get("/api/v1/capabilities") + assert response.status_code == 401 + + +def test_list_capabilities_with_auth(db_session, rbac_org, rbac_users): + app = _iam_test_app( + db_session, + lambda: Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["admin"].id, + ), + ) + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + response = client.get("/api/v1/capabilities") + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert len(response.json()) > 0 + + +def test_org_reader_workspace_admin_capabilities_but_writes_blocked( + db_session, rbac_org, rbac_users, rbac_workspace +): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + add_workspace_member( + db_session, + workspace_id=rbac_workspace.id, + user_id=rbac_users["viewer"].id, + role_id=roles[SYSTEM_ROLE_ADMIN].id, + ) + db_session.commit() + + principal = Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["viewer"].id, + ) + caps, _, _ = resolve_workspace_capabilities( + db_session, + principal=principal, + workspace_id=rbac_workspace.id, + organization_id=rbac_org.id, + ) + assert WORKSPACE_MEMBERS_MANAGE in caps + + from app.core.rbac_middleware import ReaderReadOnlyMiddleware + + app = FastAPI() + app.add_middleware(ReaderReadOnlyMiddleware) + app.include_router(workspace_iam.router, prefix="/api/v1") + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + app.dependency_overrides[get_principal] = lambda: principal + + target_user = User(id=uuid4(), email="target@test.local", name="Target") + db_session.add(target_user) + db_session.add( + OrganizationMember( + organization_id=rbac_org.id, + user_id=target_user.id, + role=RoleEnum.READER, + ) + ) + db_session.flush() + add_workspace_member( + db_session, + workspace_id=rbac_workspace.id, + user_id=target_user.id, + role_id=roles[SYSTEM_ROLE_VIEWER].id, + ) + db_session.commit() + + editor_role = roles["Editor"] + + with patch("app.core.rbac_middleware._resolve_principal", return_value=principal), patch( + "app.core.rbac_middleware.get_org_role", + return_value=RoleEnum.READER, + ): + with TestClient(app) as client: + get_resp = client.get( + f"/api/v1/workspaces/{rbac_workspace.id}/members", + headers={"Authorization": "Bearer test-token"}, + ) + patch_resp = client.patch( + f"/api/v1/workspaces/{rbac_workspace.id}/members/{target_user.id}", + json={"role_id": str(editor_role.id)}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert get_resp.status_code == 200 + assert patch_resp.status_code == 403 + assert "reader" in patch_resp.json()["detail"].lower() + + +def test_self_demote_workspace_admin_forbidden( + db_session, rbac_org, rbac_users, rbac_workspace +): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + add_workspace_member( + db_session, + workspace_id=rbac_workspace.id, + user_id=rbac_users["writer"].id, + role_id=roles[SYSTEM_ROLE_ADMIN].id, + ) + db_session.commit() + + app = _iam_test_app( + db_session, + lambda: Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["writer"].id, + ), + ) + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + response = client.patch( + f"/api/v1/workspaces/{rbac_workspace.id}/members/{rbac_users['writer'].id}", + json={"role_id": str(roles[SYSTEM_ROLE_VIEWER].id)}, + ) + + assert response.status_code == 403 + assert "cannot demote your own workspace admin role" in response.json()["detail"].lower() + + +def test_demote_other_workspace_admin_allowed( + db_session, rbac_org, rbac_users, rbac_workspace +): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + other_admin = User(id=uuid4(), email="other-admin@test.local", name="Other Admin") + db_session.add(other_admin) + db_session.add( + OrganizationMember( + organization_id=rbac_org.id, + user_id=other_admin.id, + role=RoleEnum.WRITER, + ) + ) + db_session.flush() + add_workspace_member( + db_session, + workspace_id=rbac_workspace.id, + user_id=rbac_users["writer"].id, + role_id=roles[SYSTEM_ROLE_ADMIN].id, + ) + add_workspace_member( + db_session, + workspace_id=rbac_workspace.id, + user_id=other_admin.id, + role_id=roles[SYSTEM_ROLE_ADMIN].id, + ) + db_session.commit() + + app = _iam_test_app( + db_session, + lambda: Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["writer"].id, + ), + ) + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + response = client.patch( + f"/api/v1/workspaces/{rbac_workspace.id}/members/{other_admin.id}", + json={"role_id": str(roles[SYSTEM_ROLE_VIEWER].id)}, + ) + + assert response.status_code == 200 + assert response.json()["role_name"] == SYSTEM_ROLE_VIEWER + + +def test_self_remove_requires_workspace_in_org(db_session, rbac_org, rbac_users): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + other_org = Organization(id=uuid4(), name="Other Org") + db_session.add(other_org) + db_session.flush() + other_ws = Workspace( + id=uuid4(), + organization_id=other_org.id, + name="Foreign", + slug="foreign", + is_default=False, + ) + db_session.add(other_ws) + db_session.flush() + add_workspace_member( + db_session, + workspace_id=other_ws.id, + user_id=rbac_users["viewer"].id, + role_id=roles[SYSTEM_ROLE_VIEWER].id, + ) + db_session.commit() + + app = _iam_test_app( + db_session, + lambda: Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["viewer"].id, + ), + ) + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + with TestClient(app) as client: + response = client.delete( + f"/api/v1/workspaces/{other_ws.id}/members/{rbac_users['viewer'].id}", + ) + + assert response.status_code == 404 + assert db_session.query(WorkspaceMember).filter( + WorkspaceMember.workspace_id == other_ws.id, + WorkspaceMember.user_id == rbac_users["viewer"].id, + ).first() is not None + + +def test_update_workspace_missing_returns_404_not_403( + db_session, rbac_org, rbac_users, rbac_workspace +): + app = FastAPI() + app.include_router(workspaces.router, prefix="/api/v1") + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = lambda: Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["writer"].id, + ) + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + + missing_id = uuid4() + with TestClient(app) as client: + response = client.patch( + f"/api/v1/workspaces/{missing_id}", + json={"name": "Renamed"}, + ) + + assert response.status_code == 404 + + +def test_require_capability_missing_capability_returns_403(): + """Regression: require_capability must not NameError on status import.""" + from uuid import uuid4 + + from fastapi import HTTPException + + from app.core.auth.capabilities import CALLS_DELETE, CALLS_VIEW + from app.dependencies import WorkspaceContext, require_capability + + dep = require_capability(CALLS_DELETE) + ctx = WorkspaceContext( + workspace_id=uuid4(), + organization_id=uuid4(), + capabilities=frozenset([CALLS_VIEW]), + role_name="Viewer", + ) + + with pytest.raises(HTTPException) as exc_info: + dep(ctx=ctx) + + assert exc_info.value.status_code == 403 + assert "Workspace Admin role" in exc_info.value.detail + assert "Viewer" in exc_info.value.detail + + +def test_capability_denied_message_maps_editor_and_admin(): + from app.core.auth.capabilities import ( + CALLS_DELETE, + CALLS_IMPORT, + capability_denied_message, + ) + + delete_msg = capability_denied_message( + CALLS_DELETE, + role_name="Viewer", + workspace_label="the active workspace", + ) + assert "Workspace Admin role" in delete_msg + assert "Viewer" in delete_msg + + import_msg = capability_denied_message( + CALLS_IMPORT, + role_name="Viewer", + workspace_label="the active workspace", + ) + assert "Editor role" in import_msg + assert "Viewer" in import_msg From 454fe6b357b57b46a6ab9432e062f100cacef48f Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Mon, 15 Jun 2026 07:55:12 +0000 Subject: [PATCH 10/12] fix: contributors list --- .github/workflows/docs.yml | 2 - docs-fumadocs/CUTOVER.md | 2 - docs-fumadocs/README.md | 7 +- docs-fumadocs/package.json | 4 +- .../scripts/generate-contributors.mjs | 174 ------------------ docs-fumadocs/scripts/validate-docs.mjs | 35 +--- 6 files changed, 5 insertions(+), 219 deletions(-) delete mode 100644 docs-fumadocs/scripts/generate-contributors.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7840ced1..0f759dc6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,8 +20,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/docs-fumadocs/CUTOVER.md b/docs-fumadocs/CUTOVER.md index 2c173356..8527043c 100644 --- a/docs-fumadocs/CUTOVER.md +++ b/docs-fumadocs/CUTOVER.md @@ -25,5 +25,3 @@ If production docs regress, redeploy the last known-good commit that built and e - Check CloudFront/S3 404 metrics for docs paths for at least one release window. - Track search failures and broken-link reports. - Confirm no unresolved internal links from `npm run check:links`. -- Regenerate contributor metadata weekly or when major docs edits land: - - `npm run contributors:generate` diff --git a/docs-fumadocs/README.md b/docs-fumadocs/README.md index 342753a1..9fe5d8ea 100644 --- a/docs-fumadocs/README.md +++ b/docs-fumadocs/README.md @@ -14,18 +14,17 @@ Open `http://localhost:3000/docs/intro`. - Docs content: `content/docs` - Navigation: `content/docs/meta.json` and section-level `meta.json` -- Contributor metadata: +- Contributor metadata (optional, static): - Manual owner overrides: `content/feature-owners.json` - - Generated contributor data: `content/feature-contributors.json` + - Contributor data: `content/feature-contributors.json` ## Checks ```bash -npm run contributors:generate npm run ci:check ``` -`ci:check` runs contributor consistency, docs validation, link checks, type checks, and production build. +`ci:check` runs docs validation, link checks, type checks, and production build. ## Deployment diff --git a/docs-fumadocs/package.json b/docs-fumadocs/package.json index c8eeccc5..d8bc5921 100644 --- a/docs-fumadocs/package.json +++ b/docs-fumadocs/package.json @@ -9,12 +9,10 @@ "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx", "lint": "eslint", - "contributors:generate": "node scripts/generate-contributors.mjs", - "contributors:check": "node scripts/generate-contributors.mjs --check", "search:generate": "node scripts/generate-search-index.mjs", "validate:docs": "node scripts/validate-docs.mjs", "check:links": "node scripts/check-links.mjs", - "ci:check": "npm run contributors:check && npm run validate:docs && npm run check:links && npm run types:check && npm run build", + "ci:check": "npm run validate:docs && npm run check:links && npm run types:check && npm run build", "lambda:edge:build": "node lambda-edge/enterprise-auth/build.mjs" }, "dependencies": { diff --git a/docs-fumadocs/scripts/generate-contributors.mjs b/docs-fumadocs/scripts/generate-contributors.mjs deleted file mode 100644 index d74e7d0c..00000000 --- a/docs-fumadocs/scripts/generate-contributors.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node - -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -const REPO_ROOT = path.resolve(process.cwd(), '..'); -const DOCS_APP_ROOT = process.cwd(); -const CURRENT_DOCS_ROOT = 'docs-fumadocs/content/docs'; -const CURRENT_DOCS_ABS_ROOT = path.join(DOCS_APP_ROOT, 'content', 'docs'); -const OWNERS_PATH = path.join(DOCS_APP_ROOT, 'content', 'feature-owners.json'); -const OUTPUT_PATH = path.join(DOCS_APP_ROOT, 'content', 'feature-contributors.json'); -const MAX_CONTRIBUTORS = 5; -const MIN_COMMITS = 2; -const CHECK_MODE = process.argv.includes('--check'); -const BOT_PATTERN = /(\[bot\]|dependabot|github-actions|renovate)/i; - -function runGit(args) { - return execFileSync('git', args, { - cwd: REPO_ROOT, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -} - -function discoverFeatureDocs(dirPath, prefix = '') { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const features = []; - - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - if (entry.isDirectory()) { - features.push(...discoverFeatureDocs(path.join(dirPath, entry.name), `${prefix}${entry.name}/`)); - continue; - } - - if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue; - features.push(`${prefix}${entry.name.slice(0, -4)}`); - } - - return features.sort((a, b) => a.localeCompare(b)); -} - -function historyPathFor(featureId) { - const current = `${CURRENT_DOCS_ROOT}/${featureId}.mdx`; - return current; -} - -function isBotAuthor(name, email) { - return BOT_PATTERN.test(name) || BOT_PATTERN.test(email); -} - -function parseContributors(historyPath) { - let output = ''; - try { - output = runGit(['log', '--follow', '--format=%aN|%aE', '--', historyPath]); - } catch { - return []; - } - - const counts = new Map(); - let skippedBots = 0; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - const [namePart, emailPart] = trimmed.split('|'); - const name = (namePart || '').trim(); - const email = (emailPart || '').trim().toLowerCase(); - if (!name || !email) continue; - if (isBotAuthor(name, email)) { - skippedBots += 1; - continue; - } - const key = email; - const existing = counts.get(key) || { name, email, commits: 0 }; - existing.commits += 1; - counts.set(key, existing); - } - - let contributors = Array.from(counts.values()).sort((a, b) => { - if (b.commits !== a.commits) return b.commits - a.commits; - return a.name.localeCompare(b.name); - }); - - const thresholdFiltered = contributors.filter((entry) => entry.commits >= MIN_COMMITS); - if (thresholdFiltered.length > 0) { - contributors = thresholdFiltered; - } else { - contributors = contributors.filter((entry) => entry.commits >= 1); - } - - return { - contributors: contributors.slice(0, MAX_CONTRIBUTORS), - skippedBots, - }; -} - -function readOwners() { - if (!fs.existsSync(OWNERS_PATH)) return {}; - return JSON.parse(fs.readFileSync(OWNERS_PATH, 'utf8')); -} - -function buildMetadata() { - const ownersByFeature = readOwners(); - const featureDocs = discoverFeatureDocs(CURRENT_DOCS_ABS_ROOT); - const now = new Date().toISOString().slice(0, 10); - let totalContributors = 0; - let totalSkippedBots = 0; - - const features = featureDocs.map((featureId) => { - const docPath = `${CURRENT_DOCS_ROOT}/${featureId}.mdx`; - const historyPath = historyPathFor(featureId); - const { contributors, skippedBots } = parseContributors(historyPath); - totalContributors += contributors.length; - totalSkippedBots += skippedBots; - - return { - featureId, - docPath, - historyPath, - owners: Array.isArray(ownersByFeature[featureId]) ? ownersByFeature[featureId] : [], - contributors, - lastReviewed: now, - }; - }); - - return { - source: 'git-history', - maxContributors: MAX_CONTRIBUTORS, - minCommits: MIN_COMMITS, - features, - summary: { - featureCount: featureDocs.length, - contributorCount: totalContributors, - skippedBots: totalSkippedBots, - }, - }; -} - -function normalizeForCheck(raw) { - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; - return { - ...parsed, - features: (parsed.features || []).map(({ lastReviewed, ...feature }) => feature), - }; -} - -function main() { - const metadata = buildMetadata(); - const nextData = `${JSON.stringify(metadata, null, 2)}\n`; - - if (CHECK_MODE) { - if (!fs.existsSync(OUTPUT_PATH)) { - console.error(`Missing generated file: ${OUTPUT_PATH}`); - process.exit(1); - } - const existing = fs.readFileSync(OUTPUT_PATH, 'utf8'); - const existingNormalized = JSON.stringify(normalizeForCheck(existing)); - const nextNormalized = JSON.stringify(normalizeForCheck(metadata)); - if (existingNormalized !== nextNormalized) { - console.error('feature-contributors.json is out of date. Run `npm run contributors:generate`.'); - process.exit(1); - } - return; - } - - fs.writeFileSync(OUTPUT_PATH, nextData, 'utf8'); - const parsed = JSON.parse(nextData); - console.log( - `Generated ${path.relative(DOCS_APP_ROOT, OUTPUT_PATH)} (${parsed.summary.featureCount} features, ${parsed.summary.contributorCount} contributors, ${parsed.summary.skippedBots} bot commits skipped)`, - ); -} - -main(); diff --git a/docs-fumadocs/scripts/validate-docs.mjs b/docs-fumadocs/scripts/validate-docs.mjs index f2b9f6d2..ebd1580a 100644 --- a/docs-fumadocs/scripts/validate-docs.mjs +++ b/docs-fumadocs/scripts/validate-docs.mjs @@ -5,23 +5,6 @@ import path from 'node:path'; const appRoot = process.cwd(); const docsRoot = path.join(appRoot, 'content', 'docs'); -const featureMetaPath = path.join(appRoot, 'content', 'feature-contributors.json'); -const featureDocs = new Set([ - 'products/agents', - 'products/personas', - 'products/scenarios', - 'products/evaluators', - 'products/metrics', - 'products/playground', - 'products/voice-playground', - 'products/call-imports', - 'products/alerting', - 'products/prompt-optimization', - 'products/prompt-partials', - 'monitoring/calls', - 'monitoring/alerting', - 'monitoring/cron-jobs', -]); function walkDocs(dir) { const results = []; @@ -64,7 +47,6 @@ const errors = []; const docs = walkDocs(docsRoot); for (const file of docs) { const rel = path.relative(docsRoot, file).replace(/\\/g, '/'); - const featureId = rel.replace(/\.mdx$/, ''); const content = fs.readFileSync(file, 'utf8'); const frontmatter = parseFrontmatter(content); if (!frontmatter) { @@ -74,25 +56,10 @@ for (const file of docs) { if (!frontmatter.get('title')) { errors.push(`${rel}: missing frontmatter title`); } - - // Feature pages now render contributors from a shared bottom-right widget. - // Keep validation focused on metadata presence rather than inline page markers. -} - -if (!fs.existsSync(featureMetaPath)) { - errors.push('content/feature-contributors.json does not exist'); -} else { - const featureMeta = JSON.parse(fs.readFileSync(featureMetaPath, 'utf8')); - const found = new Set((featureMeta.features || []).map((item) => item.featureId)); - for (const featureId of featureDocs) { - if (!found.has(featureId)) { - errors.push(`feature-contributors.json missing entry for ${featureId}`); - } - } } if (errors.length > 0) { fail(errors); } -console.log(`Validated ${docs.length} docs pages and contributor metadata.`); +console.log(`Validated ${docs.length} docs pages.`); From 50c1619af2744996b9cb58f687406f7c4e7b6a93 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Mon, 15 Jun 2026 09:32:03 +0000 Subject: [PATCH 11/12] feat: updating docs and capabities --- .github/workflows/docs.yml | 9 - app/api/v1/routes/call_import_evaluations.py | 37 +- app/api/v1/routes/workspace_iam.py | 14 +- app/core/auth/workspace_route_capabilities.py | 13 +- docs-fumadocs/.gitignore | 1 - docs-fumadocs/CUTOVER.md | 5 +- docs-fumadocs/README.md | 10 +- docs-fumadocs/app/docs/[[...slug]]/page.tsx | 10 - .../content/docs/enterprise/call-imports.mdx | 48 -- .../content/docs/enterprise/overview.mdx | 25 - .../docs/enterprise/prompt-optimization.mdx | 54 --- .../content/docs/enterprise/unlock.mdx | 14 - .../docs/enterprise/voice-playground.mdx | 44 -- docs-fumadocs/content/docs/meta.json | 1 - .../content/docs/products/call-imports.mdx | 4 +- .../docs/products/prompt-optimization.mdx | 4 +- .../docs/products/voice-playground.mdx | 4 +- .../lambda-edge/enterprise-auth/build.mjs | 41 -- .../lambda-edge/enterprise-auth/deploy.sh | 41 -- .../enterprise-auth/index.template.js | 155 ------- docs-fumadocs/package.json | 3 +- .../scripts/generate-search-index.mjs | 4 +- .../src/components/WorkspaceRolesSection.tsx | 432 ++++++++++++++---- .../src/hooks/useWorkspaceCapabilities.ts | 1 + frontend/src/lib/api.ts | 19 +- frontend/src/lib/apiErrors.ts | 61 +++ .../CallImportEvaluationDetail.tsx | 16 +- .../test_call_import_evaluation_pdf_report.py | 84 ++++ tests/test_api/test_workspace_iam.py | 67 +++ 29 files changed, 655 insertions(+), 566 deletions(-) delete mode 100644 docs-fumadocs/content/docs/enterprise/call-imports.mdx delete mode 100644 docs-fumadocs/content/docs/enterprise/overview.mdx delete mode 100644 docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx delete mode 100644 docs-fumadocs/content/docs/enterprise/unlock.mdx delete mode 100644 docs-fumadocs/content/docs/enterprise/voice-playground.mdx delete mode 100644 docs-fumadocs/lambda-edge/enterprise-auth/build.mjs delete mode 100755 docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh delete mode 100644 docs-fumadocs/lambda-edge/enterprise-auth/index.template.js diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0f759dc6..ba48ce4f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -68,15 +68,6 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: Deploy enterprise docs gate - run: bash deploy.sh - working-directory: docs-fumadocs/lambda-edge/enterprise-auth - env: - AWS_REGION: us-east-1 - DOCS_AUTH_LAMBDA_NAME: ${{ secrets.DOCS_AUTH_LAMBDA_NAME }} - DOCS_ENTERPRISE_PASSWORD: ${{ secrets.DOCS_ENTERPRISE_PASSWORD }} - DOCS_ENTERPRISE_COOKIE_SECRET: ${{ secrets.DOCS_ENTERPRISE_COOKIE_SECRET }} - - name: Deploy to S3 run: aws s3 sync ./out s3://${{ secrets.AWS_S3_BUCKET }} --delete diff --git a/app/api/v1/routes/call_import_evaluations.py b/app/api/v1/routes/call_import_evaluations.py index cba06861..33562c96 100644 --- a/app/api/v1/routes/call_import_evaluations.py +++ b/app/api/v1/routes/call_import_evaluations.py @@ -23,6 +23,8 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified +from app.core.auth import Principal, get_principal +from app.core.auth.capabilities import REPORTS_GENERATE, capability_denied_message from app.database import get_db from app.dependencies import ( get_api_key, @@ -30,6 +32,7 @@ get_workspace_id, require_enterprise_feature, ) +from app.services.workspace_rbac import resolve_workspace_capabilities from app.models.database import ( AIProvider, CallImport, @@ -184,6 +187,36 @@ def _require_import( return call_import +def require_call_import_capability(capability: str): + """Ensure the caller has *capability* in the call import's workspace (not just the header).""" + + def _dep( + call_import_id: UUID, + principal: Principal = Depends(get_principal), + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), + ) -> CallImport: + call_import = _require_import(db, call_import_id, organization_id) + caps, _, role = resolve_workspace_capabilities( + db, + principal=principal, + workspace_id=call_import.workspace_id, + organization_id=organization_id, + ) + if capability not in caps: + raise HTTPException( + status_code=403, + detail=capability_denied_message( + capability, + role_name=role.name if role else None, + workspace_label="the active workspace", + ), + ) + return call_import + + return _dep + + def _flatten_transcript(text: Optional[str]) -> str: """Collapse a multi-line transcript onto a single line for spreadsheet export. @@ -3220,6 +3253,7 @@ async def list_call_import_evaluation_baseline_candidates( @router.post( "/{eval_id}/pdf-report", operation_id="generateCallImportEvaluationPdfReport", + dependencies=[Depends(require_call_import_capability(REPORTS_GENERATE))], ) async def generate_call_import_evaluation_pdf_report( call_import_id: UUID, @@ -8596,7 +8630,7 @@ async def retry_call_import_evaluation_row( ) -from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW +from app.core.auth.capabilities import EVALS_RUN, EVALS_VIEW, REPORTS_GENERATE from app.core.auth.workspace_route_capabilities import apply_workspace_route_capabilities apply_workspace_route_capabilities( @@ -8604,4 +8638,5 @@ async def retry_call_import_evaluation_row( view_capability=EVALS_VIEW, manage_capability=EVALS_RUN, run_capability=EVALS_RUN, + report_capability=REPORTS_GENERATE, ) diff --git a/app/api/v1/routes/workspace_iam.py b/app/api/v1/routes/workspace_iam.py index 85a81ce8..c381f120 100644 --- a/app/api/v1/routes/workspace_iam.py +++ b/app/api/v1/routes/workspace_iam.py @@ -193,7 +193,19 @@ def update_workspace_role( raise HTTPException(status_code=400, detail="System roles cannot be modified.") if payload.name is not None: - role.name = payload.name.strip() + new_name = payload.name.strip() + conflict = ( + db.query(WorkspaceRole) + .filter( + WorkspaceRole.organization_id == organization_id, + WorkspaceRole.name == new_name, + WorkspaceRole.id != role_id, + ) + .first() + ) + if conflict: + raise HTTPException(status_code=409, detail="A role with this name already exists.") + role.name = new_name if payload.description is not None: role.description = payload.description if payload.capabilities is not None: diff --git a/app/core/auth/workspace_route_capabilities.py b/app/core/auth/workspace_route_capabilities.py index 2fb1c102..449014c8 100644 --- a/app/core/auth/workspace_route_capabilities.py +++ b/app/core/auth/workspace_route_capabilities.py @@ -16,6 +16,7 @@ def apply_workspace_route_capabilities( view_capability: str, manage_capability: str, run_capability: str | None = None, + report_capability: str | None = None, delete_capability: str | None = None, skip_paths: Iterable[str] | None = None, ) -> None: @@ -23,7 +24,8 @@ def apply_workspace_route_capabilities( Attach capability dependencies to routes on ``router`` based on HTTP method. GET/HEAD -> view_capability - POST/PUT/PATCH -> manage_capability (or run_capability when path ends with /run) + POST/PUT/PATCH -> manage_capability (or run_capability when path ends with /run, + or report_capability when path ends with /pdf-report) DELETE -> delete_capability or manage_capability """ skipped = set(skip_paths or ()) @@ -45,7 +47,9 @@ def apply_workspace_route_capabilities( deps.append(Depends(require_capability(cap))) elif methods & {"POST", "PUT", "PATCH"}: cap = manage_capability - if run_capability and _looks_like_run_route(path): + if report_capability and _looks_like_report_route(path): + cap = report_capability + elif run_capability and _looks_like_run_route(path): cap = run_capability deps.append(Depends(require_capability(cap))) @@ -55,3 +59,8 @@ def apply_workspace_route_capabilities( def _looks_like_run_route(path: str) -> bool: lowered = path.lower() return lowered.endswith("/run") or "/run/" in lowered + + +def _looks_like_report_route(path: str) -> bool: + lowered = path.lower() + return lowered.endswith("/pdf-report") or "/pdf-report" in lowered diff --git a/docs-fumadocs/.gitignore b/docs-fumadocs/.gitignore index 142f2016..5e3faef9 100644 --- a/docs-fumadocs/.gitignore +++ b/docs-fumadocs/.gitignore @@ -9,7 +9,6 @@ .next/ /out/ /build -/lambda-edge/**/dist/ *.tsbuildinfo # misc diff --git a/docs-fumadocs/CUTOVER.md b/docs-fumadocs/CUTOVER.md index 8527043c..d0dab357 100644 --- a/docs-fumadocs/CUTOVER.md +++ b/docs-fumadocs/CUTOVER.md @@ -1,6 +1,6 @@ # Docs Cutover and Stabilization -This runbook tracks the Fumadocs rollout and rollback strategy. +This runbook tracks the Fumadocs rollout strategy. ## Cutover steps @@ -11,8 +11,6 @@ This runbook tracks the Fumadocs rollout and rollback strategy. - `/docs/getting-started/installation` - `/docs/products/agents` - `/docs/monitoring/calls` - - `/docs/enterprise/unlock/` (public unlock form) - - `/docs/enterprise/overview/` (requires CloudFront Lambda@Edge password gate in production) 4. Verify contributor sections render on all feature pages. 5. Record cutover timestamp in the release notes. @@ -22,6 +20,5 @@ If production docs regress, redeploy the last known-good commit that built and e ## Stabilization checklist -- Check CloudFront/S3 404 metrics for docs paths for at least one release window. - Track search failures and broken-link reports. - Confirm no unresolved internal links from `npm run check:links`. diff --git a/docs-fumadocs/README.md b/docs-fumadocs/README.md index 9fe5d8ea..d231e29c 100644 --- a/docs-fumadocs/README.md +++ b/docs-fumadocs/README.md @@ -28,12 +28,6 @@ npm run ci:check ## Deployment -Deployment is handled by `.github/workflows/docs.yml` (the `deploy` job) and publishes static output from `docs-fumadocs/out`. +Deployment is handled by `.github/workflows/docs.yml` (the `deploy` job) and publishes static output from `docs-fumadocs/out` to S3/CloudFront on pushes to `main` (or via manual workflow dispatch). -Rollback instructions are documented in `CUTOVER.md`. - -## Enterprise documentation - -Enterprise feature guides live under `content/docs/enterprise/`. They are excluded from the public search index and marked `noindex`. - -Production access is gated at CloudFront with Lambda@Edge (`lambda-edge/enterprise-auth/`). Deployment is handled by the docs GitHub Actions workflow using repository secrets for the enterprise password and cookie signing secret. +The `build` job runs the same checks on pull requests. Only public docs content under `content/docs/` is included — there is no separate enterprise docs section or password gate. diff --git a/docs-fumadocs/app/docs/[[...slug]]/page.tsx b/docs-fumadocs/app/docs/[[...slug]]/page.tsx index be09d8e3..83e8a4a9 100644 --- a/docs-fumadocs/app/docs/[[...slug]]/page.tsx +++ b/docs-fumadocs/app/docs/[[...slug]]/page.tsx @@ -74,18 +74,8 @@ export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): P const page = source.getPage(params.slug); if (!page) notFound(); - const isEnterprise = params.slug?.[0] === 'enterprise'; - return { title: page.data.title, description: page.data.description, - ...(isEnterprise - ? { - robots: { - index: false, - follow: false, - }, - } - : {}), }; } diff --git a/docs-fumadocs/content/docs/enterprise/call-imports.mdx b/docs-fumadocs/content/docs/enterprise/call-imports.mdx deleted file mode 100644 index 399be592..00000000 --- a/docs-fumadocs/content/docs/enterprise/call-imports.mdx +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: enterprise-call-imports -title: Call Imports -sidebar_position: 3 ---- - -# Call Imports - -Call Imports is an enterprise feature (`call_imports`) for bulk-importing production call recordings via CSV and running batch evaluations on them. - -## Workflow overview - -Each import batch moves through these stages: - -1. **Upload** — upload a CSV dataset and optional audio files. -2. **Map** — map CSV columns to call-import fields using a schema. -3. **Evaluate** — run metrics and insights against imported rows. -4. **Review** — inspect per-row transcripts, scores, and insights. - -## Upload modes - -### CSV datasets - -Upload a spreadsheet of call metadata (transcripts, IDs, tags, etc.). Map columns to your schema before evaluation. - -### Audio uploads - -Upload audio files linked to import rows for diarization and transcript comparison metrics. - -## Schemas - -Define reusable column-mapping schemas under **Call Imports → Schemas**. Schemas are workspace-scoped and can be referenced by multiple import batches. - -## Tags - -Organize batches with call-import tags. Filter the imports list by tag to find related production slices. - -## Prompt partials - -Call Imports integrates with [Prompt Partials](/docs/products/prompt-partials). Use partials for evaluation rubrics and insights prompts during import workflows. - -## Transcript comparison metrics - -Enable **Compare transcripts** on a metric to run a transcript-pair judge at evaluation time. The worker feeds both the production transcript and the diarized transcript to the LLM. See [Metrics](/docs/products/metrics) for configuration details. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `call_imports` feature. Without it, import routes and the Call Imports UI are unavailable. diff --git a/docs-fumadocs/content/docs/enterprise/overview.mdx b/docs-fumadocs/content/docs/enterprise/overview.mdx deleted file mode 100644 index c126b4e4..00000000 --- a/docs-fumadocs/content/docs/enterprise/overview.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: enterprise-overview -title: Enterprise Features -sidebar_position: 1 ---- - -# Enterprise Features - -These guides cover EfficientAI features that require an enterprise license (`EFFICIENTAI_LICENSE`). - -## Available guides - -| Feature | License feature ID | Guide | -| --- | --- | --- | -| Voice Playground | `voice_playground` | [Voice Playground](/docs/enterprise/voice-playground) | -| Call Imports | `call_imports` | [Call Imports](/docs/enterprise/call-imports) | -| Prompt Optimization | `gepa_optimization` | [Prompt Optimization](/docs/enterprise/prompt-optimization) | - -## License setup - -Request an enterprise license from the EfficientAI team, then set it in `config.yml` or via the `EFFICIENTAI_LICENSE` environment variable. See [Authentication](/docs/getting-started/authentication) for the full enterprise auth and license walkthrough. - -## Documentation access - -Enterprise documentation is password-protected. If you were redirected here, enter the documentation password provided with your enterprise license. diff --git a/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx b/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx deleted file mode 100644 index b3442b1c..00000000 --- a/docs-fumadocs/content/docs/enterprise/prompt-optimization.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -id: enterprise-prompt-optimization -title: Prompt Optimization -sidebar_position: 4 ---- - -# Prompt Optimization - -Prompt Optimization is an enterprise feature (`gepa_optimization`) for iteratively improving agent prompts using evaluation feedback. - -## What gets optimized - -Each run starts from a seed prompt: - -- provider prompt, if available, otherwise -- internal agent description. - -The optimizer generates candidate prompts, evaluates them against available examples and enabled metrics, and ranks them by score. - -## Run configuration - -Common run controls include: - -- `max_metric_calls` for evaluation budget -- `minibatch_size` for examples per iteration - -These settings determine optimization depth, runtime, and cost. - -## Candidate workflow - -For each optimization run: - -1. Review generated candidates and their scores. -2. Compare candidate prompt text against the seed prompt. -3. **Accept** the candidate you want to promote. -4. Optionally **Push to Provider** to update the linked external agent prompt. - -## Push behavior - -When a candidate is pushed: - -- EfficientAI updates the prompt in the external provider, -- updates local agent prompt fields, -- records push timing for traceability. - -## Best practices - -- Use representative completed evaluator results as optimization data. -- Keep enabled metric sets stable while comparing candidates. -- Review prompt semantics in addition to score before pushing. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `gepa_optimization` feature. Without it, optimization routes and the Optimization UI are unavailable. diff --git a/docs-fumadocs/content/docs/enterprise/unlock.mdx b/docs-fumadocs/content/docs/enterprise/unlock.mdx deleted file mode 100644 index 59cd149d..00000000 --- a/docs-fumadocs/content/docs/enterprise/unlock.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -id: enterprise-unlock -title: Unlock Enterprise Docs ---- - -import { EnterpriseUnlockForm } from '@/components/enterprise-unlock-form'; - -# Enterprise Documentation - -These guides are available to enterprise customers. Enter the documentation password shared with your organization. - - - -If you do not have a password, contact the EfficientAI team or your account representative. diff --git a/docs-fumadocs/content/docs/enterprise/voice-playground.mdx b/docs-fumadocs/content/docs/enterprise/voice-playground.mdx deleted file mode 100644 index b02ef837..00000000 --- a/docs-fumadocs/content/docs/enterprise/voice-playground.mdx +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: enterprise-voice-playground -title: Voice Playground -sidebar_position: 2 ---- - -# Voice Playground - -Voice Playground is an enterprise feature (`voice_playground`) for A/B testing TTS providers with real synthesis, blind tests, and automated evaluation. - -## What it does - -From the Voice Playground you can: - -- compare multiple TTS voices side by side on the same script, -- run benchmark simulations across providers, -- create blind-test shares for human raters, -- evaluate outputs with workspace [metrics](/docs/products/metrics) enabled for the `voice_playground` surface. - -## Tabs - -### Playground - -Configure a comparison: pick voices, enter or paste script text, and synthesize audio from each provider. Review waveforms, playback, and per-voice metric scores. - -### Voices - -Manage custom voice entries used in comparisons. Custom voices persist in your active [workspace](/docs/getting-started/workspaces). - -### Simulations - -Browse past benchmark runs. Re-open a comparison to review scores, transcripts, and metric breakdowns. - -### Blind Tests - -Create shareable blind-test links so raters can compare samples without seeing provider names. Owner-side share management requires the `voice_playground` license. - -## Metrics - -Enable metrics for the **Voice Playground** surface under [Metrics](/docs/products/metrics). Only metrics with that surface enabled appear during playground evaluation. - -## License requirement - -Set `EFFICIENTAI_LICENSE` with the `voice_playground` feature. Without it, the app shows the enterprise upgrade screen and API routes return `403 enterprise_feature_required`. diff --git a/docs-fumadocs/content/docs/meta.json b/docs-fumadocs/content/docs/meta.json index 4faa569f..61851900 100644 --- a/docs-fumadocs/content/docs/meta.json +++ b/docs-fumadocs/content/docs/meta.json @@ -7,7 +7,6 @@ "monitoring", "reference", "advanced", - "enterprise", "more" ] } diff --git a/docs-fumadocs/content/docs/products/call-imports.mdx b/docs-fumadocs/content/docs/products/call-imports.mdx index 614fe3df..54503aee 100644 --- a/docs-fumadocs/content/docs/products/call-imports.mdx +++ b/docs-fumadocs/content/docs/products/call-imports.mdx @@ -8,7 +8,7 @@ sidebar_position: 9 Call Imports lets you bulk-import production call recordings via CSV and run batch evaluations on them. -> **Enterprise feature** — requires `call_imports` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/call-imports) (password required). +> **Enterprise feature** — requires `call_imports` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,4 +16,4 @@ Call Imports lets you bulk-import production call recordings via CSV and run bat - Map columns with reusable schemas - Run metrics and insights across imported production calls -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/content/docs/products/prompt-optimization.mdx b/docs-fumadocs/content/docs/products/prompt-optimization.mdx index 65462fb4..7c9142f7 100644 --- a/docs-fumadocs/content/docs/products/prompt-optimization.mdx +++ b/docs-fumadocs/content/docs/products/prompt-optimization.mdx @@ -8,7 +8,7 @@ sidebar_position: 7 Prompt Optimization is an enterprise feature for iteratively improving agent prompts using evaluation feedback. -> **Enterprise feature** — requires `gepa_optimization` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/prompt-optimization) (password required). +> **Enterprise feature** — requires `gepa_optimization` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,5 +16,5 @@ Prompt Optimization is an enterprise feature for iteratively improving agent pro - Accept winning candidates and optionally push to your voice provider - Control optimization depth with `max_metric_calls` and `minibatch_size` -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/content/docs/products/voice-playground.mdx b/docs-fumadocs/content/docs/products/voice-playground.mdx index e17a73d7..a9235595 100644 --- a/docs-fumadocs/content/docs/products/voice-playground.mdx +++ b/docs-fumadocs/content/docs/products/voice-playground.mdx @@ -8,7 +8,7 @@ sidebar_position: 8 Voice Playground lets you A/B test TTS providers with real synthesis, blind tests, and automated evaluation. -> **Enterprise feature** — requires `voice_playground` in your `EFFICIENTAI_LICENSE`. Full documentation is available in the [Enterprise docs](/docs/enterprise/voice-playground) (password required). +> **Enterprise feature** — requires `voice_playground` in your `EFFICIENTAI_LICENSE`. ## At a glance @@ -16,4 +16,4 @@ Voice Playground lets you A/B test TTS providers with real synthesis, blind test - Run benchmark simulations and blind-test shares - Evaluate with metrics enabled for the Voice Playground surface -Contact the EfficientAI team for a license and enterprise documentation access. +Contact the EfficientAI team for a license. diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs b/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs deleted file mode 100644 index f3601498..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/build.mjs +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const templatePath = path.join(dirname, 'index.template.js'); -const outputPath = path.join(dirname, 'dist', 'index.js'); - -function requireEnv(name) { - const value = process.env[name]; - if (!value) { - console.error(`Missing required environment variable: ${name}`); - process.exit(1); - } - return value; -} - -function main() { - const password = requireEnv('DOCS_ENTERPRISE_PASSWORD'); - const cookieSecret = process.env.DOCS_ENTERPRISE_COOKIE_SECRET || crypto.randomBytes(32).toString('hex'); - const passwordHash = crypto.createHash('sha256').update(password).digest('hex'); - - const template = fs.readFileSync(templatePath, 'utf8'); - const output = template - .replaceAll('{{PASSWORD_HASH}}', passwordHash) - .replaceAll('{{COOKIE_SECRET}}', cookieSecret); - - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, output, 'utf8'); - - console.log(`Built ${path.relative(process.cwd(), outputPath)}`); - if (!process.env.DOCS_ENTERPRISE_COOKIE_SECRET) { - console.log('Generated ephemeral DOCS_ENTERPRISE_COOKIE_SECRET for this build.'); - console.log('Set DOCS_ENTERPRISE_COOKIE_SECRET in your deploy environment to keep sessions stable across redeploys.'); - } -} - -main(); diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh b/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh deleted file mode 100755 index c43473db..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/deploy.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DIST_DIR="${ROOT_DIR}/dist" -ZIP_PATH="${DIST_DIR}/enterprise-auth.zip" -FUNCTION_NAME="${DOCS_AUTH_LAMBDA_NAME:-efficientai-docs-enterprise-auth}" -AWS_REGION="${AWS_REGION:-us-east-1}" - -if [[ -z "${DOCS_ENTERPRISE_PASSWORD:-}" ]]; then - echo "Set DOCS_ENTERPRISE_PASSWORD before building/deploying." >&2 - exit 1 -fi - -node "${ROOT_DIR}/build.mjs" - -rm -f "${ZIP_PATH}" -(cd "${DIST_DIR}" && zip -q "${ZIP_PATH}" index.js) - -if [[ "${1:-}" == "--build-only" ]]; then - echo "Built ${ZIP_PATH}" - exit 0 -fi - -aws lambda update-function-code \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" \ - --zip-file "fileb://${ZIP_PATH}" - -aws lambda wait function-updated \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" - -NEW_VERSION="$(aws lambda publish-version \ - --region "${AWS_REGION}" \ - --function-name "${FUNCTION_NAME}" \ - --query 'Version' \ - --output text)" - -echo "Published Lambda version: ${NEW_VERSION}" -echo "Associate this version with your CloudFront /docs/enterprise/* cache behavior (viewer-request)." diff --git a/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js b/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js deleted file mode 100644 index 88fae928..00000000 --- a/docs-fumadocs/lambda-edge/enterprise-auth/index.template.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); - -const PASSWORD_HASH = '{{PASSWORD_HASH}}'; -const COOKIE_SECRET = '{{COOKIE_SECRET}}'; -const SESSION_DAYS = 7; - -const COOKIE_NAME = 'docs_ent'; -const UNLOCK_PREFIX = '/docs/enterprise/unlock'; -const ENTERPRISE_PREFIX = '/docs/enterprise/'; - -function timingSafeEqual(a, b) { - if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) { - return false; - } - let result = 0; - for (let i = 0; i < a.length; i += 1) { - result |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return result === 0; -} - -function sha256(value) { - return crypto.createHash('sha256').update(value).digest('hex'); -} - -function signSession(expiry) { - return crypto.createHmac('sha256', COOKIE_SECRET).update(String(expiry)).digest('base64url'); -} - -function verifySession(value) { - if (!value) return false; - const separator = value.lastIndexOf('.'); - if (separator === -1) return false; - - const expiry = Number.parseInt(value.slice(0, separator), 10); - const signature = value.slice(separator + 1); - if (!Number.isFinite(expiry) || expiry < Date.now()) return false; - - const expected = signSession(expiry); - return timingSafeEqual(signature, expected); -} - -function parseCookies(headers) { - const cookieHeader = headers.cookie; - if (!cookieHeader) return {}; - - const cookies = {}; - for (const item of cookieHeader) { - const parts = item.value.split(';'); - for (const part of parts) { - const trimmed = part.trim(); - const separator = trimmed.indexOf('='); - if (separator === -1) continue; - const key = trimmed.slice(0, separator); - const value = trimmed.slice(separator + 1); - cookies[key] = value; - } - } - return cookies; -} - -function parseFormBody(body, isBase64) { - const raw = isBase64 ? Buffer.from(body, 'base64').toString('utf8') : body; - const params = new URLSearchParams(raw); - return { - password: params.get('password') || '', - returnTo: params.get('return') || '/docs/enterprise/overview/', - }; -} - -function normalizeUri(uri) { - return uri.endsWith('/') ? uri : `${uri}/`; -} - -function isUnlockPath(uri) { - return uri === '/docs/enterprise/unlock' || uri.startsWith('/docs/enterprise/unlock/'); -} - -function redirect(location, extraHeaders) { - const response = { - status: '302', - statusDescription: 'Found', - headers: { - location: [{ key: 'Location', value: location }], - 'cache-control': [{ key: 'Cache-Control', value: 'no-store' }], - }, - }; - - if (extraHeaders) { - Object.assign(response.headers, extraHeaders); - } - - return response; -} - -function passThrough(request) { - return request; -} - -exports.handler = async (event) => { - const request = event.Records[0].cf.request; - const uri = normalizeUri(request.uri); - - if (!uri.startsWith(ENTERPRISE_PREFIX)) { - return passThrough(request); - } - - if (isUnlockPath(uri)) { - if (request.method === 'POST' && request.body && request.body.data) { - const { password, returnTo } = parseFormBody( - request.body.data, - request.body.encoding === 'base64', - ); - const submittedHash = sha256(password); - - if (!timingSafeEqual(submittedHash, PASSWORD_HASH)) { - const errorTarget = `${UNLOCK_PREFIX}/?error=invalid&return=${encodeURIComponent(returnTo)}`; - return redirect(errorTarget); - } - - const expiry = Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000; - const cookieValue = `${expiry}.${signSession(expiry)}`; - const safeReturn = sanitizeReturnPath(returnTo); - - return redirect(safeReturn, { - 'set-cookie': [{ - key: 'Set-Cookie', - value: `${COOKIE_NAME}=${cookieValue}; Path=/docs/enterprise/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}`, - }], - }); - } - - return passThrough(request); - } - - const cookies = parseCookies(request.headers); - if (verifySession(cookies[COOKIE_NAME])) { - return passThrough(request); - } - - const returnTo = encodeURIComponent(uri); - return redirect(`${UNLOCK_PREFIX}/?return=${returnTo}`); -}; - -function sanitizeReturnPath(value) { - if (!value || !value.startsWith('/docs/enterprise/')) { - return '/docs/enterprise/overview/'; - } - if (isUnlockPath(value)) { - return '/docs/enterprise/overview/'; - } - return value.endsWith('/') ? value : `${value}/`; -} diff --git a/docs-fumadocs/package.json b/docs-fumadocs/package.json index d8bc5921..d24c6180 100644 --- a/docs-fumadocs/package.json +++ b/docs-fumadocs/package.json @@ -12,8 +12,7 @@ "search:generate": "node scripts/generate-search-index.mjs", "validate:docs": "node scripts/validate-docs.mjs", "check:links": "node scripts/check-links.mjs", - "ci:check": "npm run validate:docs && npm run check:links && npm run types:check && npm run build", - "lambda:edge:build": "node lambda-edge/enterprise-auth/build.mjs" + "ci:check": "npm run validate:docs && npm run check:links && npm run types:check && npm run build" }, "dependencies": { "fumadocs-core": "16.8.11", diff --git a/docs-fumadocs/scripts/generate-search-index.mjs b/docs-fumadocs/scripts/generate-search-index.mjs index 7fe5728c..f17f13cd 100644 --- a/docs-fumadocs/scripts/generate-search-index.mjs +++ b/docs-fumadocs/scripts/generate-search-index.mjs @@ -67,9 +67,7 @@ function toLabel(segment) { function buildRecords() { const files = walkDocs(DOCS_ROOT); - const records = files - .filter(({ relative }) => !relative.startsWith('enterprise/')) - .map(({ relative, absolute }) => { + const records = files.map(({ relative, absolute }) => { const raw = fs.readFileSync(absolute, 'utf8'); const { frontmatter, body } = stripFrontmatter(raw); const featureId = relative.replace(/\.mdx$/, ''); diff --git a/frontend/src/components/WorkspaceRolesSection.tsx b/frontend/src/components/WorkspaceRolesSection.tsx index 25429236..fbf329e2 100644 --- a/frontend/src/components/WorkspaceRolesSection.tsx +++ b/frontend/src/components/WorkspaceRolesSection.tsx @@ -1,20 +1,30 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' -import { Plus, Trash2 } from 'lucide-react' +import { ChevronDown, Pencil, Plus, Trash2, X } from 'lucide-react' import { apiClient } from '../lib/api' +import { getApiErrorMessage } from '../lib/apiErrors' import type { CapabilityDomain, CapabilityInfo, WorkspaceRole } from '../types/api' import Button from './Button' import { useToast } from '../hooks/useToast' +function buildCapabilityLabelMap(domains: CapabilityDomain[]): Map { + const map = new Map() + for (const domain of domains) { + for (const cap of domain.capabilities) { + map.set(cap.key, cap.label) + } + } + return map +} + export default function WorkspaceRolesSection() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const [showCreate, setShowCreate] = useState(false) - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [selectedCaps, setSelectedCaps] = useState>(new Set()) + const [expandedRoleIds, setExpandedRoleIds] = useState>(new Set()) + const [editingRoleId, setEditingRoleId] = useState(null) - const { data: roles = [] } = useQuery({ + const { data: roles = [], isLoading: rolesLoading, error: rolesError } = useQuery({ queryKey: ['workspace-roles'], queryFn: () => apiClient.listWorkspaceRoles(), }) @@ -24,23 +34,36 @@ export default function WorkspaceRolesSection() { queryFn: () => apiClient.listCapabilities(), }) + const capLabels = useMemo(() => buildCapabilityLabelMap(domains), [domains]) + const createMutation = useMutation({ - mutationFn: () => - apiClient.createWorkspaceRole({ - name, - description: description || undefined, - capabilities: Array.from(selectedCaps), - }), + mutationFn: (payload: { name: string; description?: string; capabilities: string[] }) => + apiClient.createWorkspaceRole(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) setShowCreate(false) - setName('') - setDescription('') - setSelectedCaps(new Set()) showToast('Role created', 'success') }, - onError: (error: any) => { - showToast(error.response?.data?.detail || 'Failed to create role', 'error') + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to create role'), 'error') + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ + roleId, + payload, + }: { + roleId: string + payload: { name?: string; description?: string | null; capabilities?: string[] } + }) => apiClient.updateWorkspaceRole(roleId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setEditingRoleId(null) + showToast('Role updated', 'success') + }, + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to update role'), 'error') }, }) @@ -48,22 +71,14 @@ export default function WorkspaceRolesSection() { mutationFn: (roleId: string) => apiClient.deleteWorkspaceRole(roleId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workspace-roles'] }) + setEditingRoleId(null) showToast('Role deleted', 'success') }, - onError: (error: any) => { - showToast(error.response?.data?.detail || 'Failed to delete role', 'error') + onError: (error: unknown) => { + showToast(getApiErrorMessage(error, 'Failed to delete role'), 'error') }, }) - const toggleCap = (cap: string) => { - setSelectedCaps((prev) => { - const next = new Set(prev) - if (next.has(cap)) next.delete(cap) - else next.add(cap) - return next - }) - } - const systemRoles = useMemo( () => roles.filter((r: WorkspaceRole) => r.is_system), [roles], @@ -73,6 +88,21 @@ export default function WorkspaceRolesSection() { [roles], ) + const toggleExpanded = (roleId: string) => { + setExpandedRoleIds((prev) => { + const next = new Set(prev) + if (next.has(roleId)) next.delete(roleId) + else next.add(roleId) + return next + }) + } + + const startEdit = (role: WorkspaceRole) => { + setEditingRoleId(role.id) + setExpandedRoleIds((prev) => new Set(prev).add(role.id)) + setShowCreate(false) + } + return (
@@ -83,91 +113,327 @@ export default function WorkspaceRolesSection() { System roles are predefined. Org admins can create custom roles from capabilities.

-
+ {rolesError && ( +
+ {getApiErrorMessage(rolesError, 'Failed to load workspace roles')} +
+ )} + {showCreate && ( -
- setName(e.target.value)} - placeholder="Role name" - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" - /> - setDescription(e.target.value)} - placeholder="Description (optional)" - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + setShowCreate(false)} + onSubmit={(values) => createMutation.mutate(values)} + /> + )} + + {rolesLoading ? ( +
Loading roles…
+ ) : ( + <> + - deleteMutation.mutate(id)} + onCancelEdit={() => setEditingRoleId(null)} + onSaveEdit={(roleId, values) => updateMutation.mutate({ roleId, payload: values })} + isSaving={updateMutation.isPending} /> - -
+ )} - - - deleteMutation.mutate(id)} - />
) } function RoleList({ title, + emptyMessage, roles, + domains, + capLabels, + expandedRoleIds, + editingRoleId, + onToggleExpand, + onEdit, onDelete, + onCancelEdit, + onSaveEdit, + isSaving, }: { title: string + emptyMessage: string roles: WorkspaceRole[] + domains: CapabilityDomain[] + capLabels: Map + expandedRoleIds: Set + editingRoleId: string | null + onToggleExpand: (roleId: string) => void + onEdit?: (role: WorkspaceRole) => void onDelete?: (id: string) => void + onCancelEdit?: () => void + onSaveEdit?: ( + roleId: string, + values: { name: string; description?: string; capabilities: string[] }, + ) => void + isSaving?: boolean }) { - if (!roles.length) return null return (

{title}

-
- {roles.map((role) => ( -
-
-
{role.name}
- {role.description && ( -
{role.description}
- )} -
- {role.capabilities.length} capabilities + {!roles.length ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {roles.map((role) => { + const expanded = expandedRoleIds.has(role.id) + const isEditing = editingRoleId === role.id + + return ( +
+
+ + +
+ {onEdit && !isEditing && ( + + )} + {onDelete && !isEditing && ( + + )} +
+
+ + {expanded && !isEditing && ( + + )} + + {expanded && isEditing && onCancelEdit && onSaveEdit && ( +
+ onSaveEdit(role.id, values)} + /> +
+ )}
+ ) + })} +
+ )} +
+ ) +} + +function CapabilityDetails({ + role, + domains, + capLabels, +}: { + role: WorkspaceRole + domains: CapabilityDomain[] + capLabels: Map +}) { + const assigned = new Set(role.capabilities) + + return ( +
+ {domains.map((domain) => { + const domainCaps = domain.capabilities.filter((cap) => assigned.has(cap.key)) + if (!domainCaps.length) return null + + return ( +
+
+ {domain.label} +
+
+ {domainCaps.map((cap) => ( + + {capLabels.get(cap.key) ?? cap.label} + + ))}
- {onDelete && ( - - )}
- ))} + ) + })} + {role.capabilities.length === 0 && ( +
No capabilities assigned.
+ )} +
+ ) +} + +function RoleForm({ + title, + initialName = '', + initialDescription = '', + initialCapabilities = [], + domains, + submitLabel, + isPending, + onSubmit, + onCancel, +}: { + title: string + initialName?: string + initialDescription?: string + initialCapabilities?: string[] + domains: CapabilityDomain[] + submitLabel: string + isPending: boolean + onSubmit: (values: { name: string; description?: string; capabilities: string[] }) => void + onCancel: () => void +}) { + const [name, setName] = useState(initialName) + const [description, setDescription] = useState(initialDescription) + const [selectedCaps, setSelectedCaps] = useState>( + () => new Set(initialCapabilities), + ) + + const toggleCap = (cap: string) => { + setSelectedCaps((prev) => { + const next = new Set(prev) + if (next.has(cap)) next.delete(cap) + else next.add(cap) + return next + }) + } + + return ( +
+
+

{title}

+ +
+ setName(e.target.value)} + placeholder="Role name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white" + /> + setDescription(e.target.value)} + placeholder="Description (optional)" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white" + /> + +
+ +
) diff --git a/frontend/src/hooks/useWorkspaceCapabilities.ts b/frontend/src/hooks/useWorkspaceCapabilities.ts index 03353671..58733814 100644 --- a/frontend/src/hooks/useWorkspaceCapabilities.ts +++ b/frontend/src/hooks/useWorkspaceCapabilities.ts @@ -14,6 +14,7 @@ export function useWorkspaceCapabilities() { canImportCalls: capabilities.includes('calls.import'), canManageMetrics: capabilities.includes('metrics.manage'), canRunEvals: capabilities.includes('evals.run'), + canGenerateReports: capabilities.includes('reports.generate'), }), [capabilities], ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 724ed02e..e137c58d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -296,16 +296,29 @@ class ApiClient { // Add response interceptor for error handling this.client.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + const response = error.response + if (response?.data instanceof Blob) { + try { + const text = await response.data.text() + const trimmed = text.trim() + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + response.data = JSON.parse(text) + } + } catch { + // Keep the original blob when it isn't JSON. + } + } + // Only log out on 401 (authentication failure) // 403 errors are authorization failures that should be handled by the calling code - if (error.response?.status === 401) { + if (response?.status === 401) { // API key invalid, clear it localStorage.removeItem('apiKey') window.location.href = '/login' } return Promise.reject(error) - } + }, ) } diff --git a/frontend/src/lib/apiErrors.ts b/frontend/src/lib/apiErrors.ts index 37731a75..deefe935 100644 --- a/frontend/src/lib/apiErrors.ts +++ b/frontend/src/lib/apiErrors.ts @@ -10,6 +10,11 @@ export function getApiErrorMessage(error: unknown, fallback: string): string { return detail } + if (typeof detail === 'object' && detail !== null && 'message' in detail) { + const message = String((detail as { message: unknown }).message) + if (message.trim()) return message + } + if (Array.isArray(detail)) { const message = detail .map((item) => { @@ -29,3 +34,59 @@ export function getApiErrorMessage(error: unknown, fallback: string): string { return fallback } + +/** Like getApiErrorMessage, but parses JSON error bodies returned as Blob (e.g. responseType: 'blob'). */ +export async function getBlobApiErrorMessage( + error: unknown, + fallback: string, +): Promise { + const err = error as { response?: { data?: unknown } } + const data = err?.response?.data + + if (data && typeof data === 'object' && !(data instanceof Blob)) { + return getApiErrorMessage(error, fallback) + } + + const text = await readResponseBodyText(data) + if (text) { + try { + const parsed = JSON.parse(text) as { detail?: unknown } + if (typeof parsed.detail === 'string' && parsed.detail.trim()) { + return parsed.detail + } + if ( + typeof parsed.detail === 'object' && + parsed.detail !== null && + 'message' in parsed.detail + ) { + const message = String((parsed.detail as { message: unknown }).message) + if (message.trim()) return message + } + } catch { + if (text.trim()) return text.trim() + } + } + + return getApiErrorMessage(error, fallback) +} + +async function readResponseBodyText(data: unknown): Promise { + if (data instanceof Blob) { + return data.text() + } + if (typeof data === 'string') { + return data + } + if (data instanceof ArrayBuffer) { + return new TextDecoder().decode(data) + } + if ( + typeof data === 'object' && + data !== null && + 'text' in data && + typeof (data as { text: () => Promise }).text === 'function' + ) { + return (data as { text: () => Promise }).text() + } + return null +} diff --git a/frontend/src/pages/callImports/CallImportEvaluationDetail.tsx b/frontend/src/pages/callImports/CallImportEvaluationDetail.tsx index 6a3aa249..d0a52b52 100644 --- a/frontend/src/pages/callImports/CallImportEvaluationDetail.tsx +++ b/frontend/src/pages/callImports/CallImportEvaluationDetail.tsx @@ -1698,12 +1698,10 @@ export default function CallImportEvaluationDetail() { setPdfReportType('external') setPdfIncludeWeeklyDelta(false) setPdfUseCase('') - } catch (e: any) { + } catch (e: unknown) { console.error('Failed to generate PDF report', e) - setPdfReportError( - e?.response?.data?.detail || - 'Failed to generate PDF report. Please try again.', - ) + const message = getApiErrorMessage(e, 'Failed to generate PDF report. Please try again.') + showToast(message, 'error') } finally { setPdfReportLoadingAction(null) } @@ -1732,12 +1730,10 @@ export default function CallImportEvaluationDetail() { `${vendorSlug}-${pdfReportType}-quality-metric-audit-${evalId}.pdf`, ) setPdfPreviewOpen(true) - } catch (e: any) { + } catch (e: unknown) { console.error('Failed to preview PDF report', e) - setPdfReportError( - e?.response?.data?.detail || - 'Failed to generate PDF preview. Please try again.', - ) + const message = getApiErrorMessage(e, 'Failed to generate PDF preview. Please try again.') + showToast(message, 'error') } finally { setPdfReportLoadingAction(null) } diff --git a/tests/test_api/test_call_import_evaluation_pdf_report.py b/tests/test_api/test_call_import_evaluation_pdf_report.py index 86d753e6..3edec148 100644 --- a/tests/test_api/test_call_import_evaluation_pdf_report.py +++ b/tests/test_api/test_call_import_evaluation_pdf_report.py @@ -1408,3 +1408,87 @@ def test_pdf_report_html_includes_brand_logos_on_first_page_header( assert "Internal brand" in first_page_header assert "External vendor brand" in first_page_header assert "Acme Branch" in first_page_header + + +def test_pdf_report_denied_for_workspace_viewer(db_session, org_id, seed_org, monkeypatch): + """Workspace Viewers may view evals but must not generate PDF reports.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1.routes import call_import_evaluations + from app.core.auth.capabilities import SYSTEM_ROLE_VIEWER + from app.core.auth.dependency import get_principal + from app.core.auth.principal import AuthMethod, Principal + from app.database import get_db + from app import dependencies as app_dependencies + from app.dependencies import get_organization_id + from app.models.database import OrganizationMember, RoleEnum, User, Workspace + from app.services.workspace_rbac import ( + add_workspace_member, + seed_system_workspace_roles, + ) + + call_import, evaluation = _seed_completed_evaluation(db_session, org_id) + roles = seed_system_workspace_roles(db_session, organization_id=org_id) + viewer = User(id=uuid4(), email="viewer-pdf@test.local", name="Viewer") + db_session.add(viewer) + db_session.add( + OrganizationMember( + organization_id=org_id, + user_id=viewer.id, + role=RoleEnum.WRITER, + ) + ) + db_session.flush() + add_workspace_member( + db_session, + workspace_id=call_import.workspace_id, + user_id=viewer.id, + role_id=roles[SYSTEM_ROLE_VIEWER].id, + ) + db_session.commit() + + monkeypatch.setattr( + call_import_evaluation_pdf_report_service, + "_render_weasyprint", + lambda _html, **_kwargs: b"%PDF-1.4 test", + ) + monkeypatch.setattr( + app_dependencies, + "is_feature_enabled", + lambda *_args, **_kwargs: True, + ) + + app = FastAPI() + app.include_router(call_import_evaluations.router, prefix="/api/v1") + + def _principal(): + return Principal( + organization_id=org_id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=viewer.id, + ) + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = _principal + app.dependency_overrides[get_organization_id] = lambda: org_id + + workspace = ( + db_session.query(Workspace) + .filter(Workspace.id == call_import.workspace_id) + .first() + ) + + with TestClient(app) as client: + response = client.post( + f"/api/v1/call-imports/{call_import.id}/evaluations/{evaluation.id}/pdf-report", + headers={"X-Workspace-Id": str(workspace.id)}, + json={"vendor_name": "Acme"}, + ) + + assert response.status_code == 403 + assert "Editor role" in response.json()["detail"] + assert "Viewer" in response.json()["detail"] diff --git a/tests/test_api/test_workspace_iam.py b/tests/test_api/test_workspace_iam.py index 5fab8b69..3378fa5f 100644 --- a/tests/test_api/test_workspace_iam.py +++ b/tests/test_api/test_workspace_iam.py @@ -539,3 +539,70 @@ def test_capability_denied_message_maps_editor_and_admin(): ) assert "Editor role" in import_msg assert "Viewer" in import_msg + + +def _iam_admin_client(db_session, rbac_org, rbac_users): + app = FastAPI() + app.include_router(workspace_iam.router, prefix="/api/v1") + + def _principal(): + return Principal( + organization_id=rbac_org.id, + auth_method=AuthMethod.LOCAL_PASSWORD, + user_id=rbac_users["admin"].id, + ) + + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_principal] = _principal + app.dependency_overrides[get_organization_id] = lambda: rbac_org.id + return TestClient(app) + + +def test_workspace_role_crud(db_session, rbac_org, rbac_users): + with _iam_admin_client(db_session, rbac_org, rbac_users) as client: + create = client.post( + "/api/v1/workspace-roles", + json={ + "name": "Eval Runner", + "description": "Can view and run evals", + "capabilities": ["evals.view", "evals.run"], + }, + ) + assert create.status_code == 201 + role_id = create.json()["id"] + assert create.json()["is_system"] is False + + listed = client.get("/api/v1/workspace-roles") + assert listed.status_code == 200 + names = {r["name"] for r in listed.json()} + assert "Eval Runner" in names + + update = client.patch( + f"/api/v1/workspace-roles/{role_id}", + json={ + "name": "Eval Runner Plus", + "capabilities": ["evals.view", "evals.run", "reports.view"], + }, + ) + assert update.status_code == 200 + assert update.json()["name"] == "Eval Runner Plus" + assert "reports.view" in update.json()["capabilities"] + + delete = client.delete(f"/api/v1/workspace-roles/{role_id}") + assert delete.status_code == 204 + + +def test_cannot_update_system_workspace_role(db_session, rbac_org, rbac_users): + roles = seed_system_workspace_roles(db_session, organization_id=rbac_org.id) + viewer_role = roles[SYSTEM_ROLE_VIEWER] + + with _iam_admin_client(db_session, rbac_org, rbac_users) as client: + response = client.patch( + f"/api/v1/workspace-roles/{viewer_role.id}", + json={"name": "Renamed Viewer"}, + ) + assert response.status_code == 400 + assert "System roles cannot be modified" in response.json()["detail"] From d96be92da1d5ee58ed26294c60461513366fca50 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Mon, 15 Jun 2026 09:40:28 +0000 Subject: [PATCH 12/12] minor fix --- tests/test_api/test_call_import_evaluation_pdf_report.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_api/test_call_import_evaluation_pdf_report.py b/tests/test_api/test_call_import_evaluation_pdf_report.py index 3edec148..39293bfe 100644 --- a/tests/test_api/test_call_import_evaluation_pdf_report.py +++ b/tests/test_api/test_call_import_evaluation_pdf_report.py @@ -112,9 +112,12 @@ def _seed_completed_evaluation(db_session, org_id): return call_import, evaluation -def test_pdf_report_rejects_blank_vendor_name(authenticated_client): +def test_pdf_report_rejects_blank_vendor_name( + authenticated_client, db_session, org_id, seed_org +): + call_import, evaluation = _seed_completed_evaluation(db_session, org_id) response = authenticated_client.post( - f"/api/v1/call-imports/{uuid4()}/evaluations/{uuid4()}/pdf-report", + f"/api/v1/call-imports/{call_import.id}/evaluations/{evaluation.id}/pdf-report", json={"vendor_name": " "}, )