From d16e29418559a43d08462e5f54200cb52ddb2145 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 19:11:13 +0800 Subject: [PATCH] feat(gateway): service-accounts management endpoints (Stage 1 PR4) Owner/admin self-service CRUD for service accounts: POST create, GET list, PATCH status; workspace-scoped with 404 existence hiding for cross-workspace targets. Gated by require_workspace_admin. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/gateway/app.py | 4 + .../app/gateway/routers/service_accounts.py | 76 +++++++++++ backend/tests/test_service_accounts_router.py | 123 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 backend/app/gateway/routers/service_accounts.py create mode 100644 backend/tests/test_service_accounts_router.py diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 1f0684fa..103c1be5 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -22,6 +22,7 @@ from app.gateway.routers import ( memory, models, runs, + service_accounts, skills, suggestions, thread_runs, @@ -411,6 +412,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Auth API is mounted at /api/v1/auth app.include_router(auth.router) + # Service Accounts API is mounted at /api/v1/service-accounts + app.include_router(service_accounts.router) + # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback app.include_router(feedback.router) diff --git a/backend/app/gateway/routers/service_accounts.py b/backend/app/gateway/routers/service_accounts.py new file mode 100644 index 00000000..a6c85a09 --- /dev/null +++ b/backend/app/gateway/routers/service_accounts.py @@ -0,0 +1,76 @@ +"""Service account management endpoints (Stage 1 PR4). + +Owner/admin self-service: create / list / suspend service accounts in +the caller's current workspace. All operations are workspace-scoped; +cross-workspace targets return 404 (existence hidden). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.authz import require_workspace_admin +from deerflow.persistence.service_account import ServiceAccountRepository +from deerflow.runtime.workspace_context import get_current_workspace + +router = APIRouter(prefix="/api/v1/service-accounts", tags=["service-accounts"]) + + +class CreateServiceAccountRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + role: str = Field(default="member") + identity_mode: str = Field(default="collapsed") + + +class UpdateServiceAccountRequest(BaseModel): + status: str = Field(..., pattern="^(active|suspended|deleted)$") + + +def get_service_account_repo() -> ServiceAccountRepository: + from deerflow.persistence.engine import get_session_factory + + sf = get_session_factory() + if sf is None: + raise HTTPException(status_code=503, detail="persistence backend not available") + return ServiceAccountRepository(sf) + + +def _current_workspace_id() -> str: + ws = get_current_workspace() + if ws is None: + raise HTTPException(status_code=403, detail="no workspace in context") + return str(ws.id) + + +@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)]) +async def create_service_account( + body: CreateServiceAccountRequest, + request: Request, + repo: ServiceAccountRepository = Depends(get_service_account_repo), +): + return await repo.create( + workspace_id=_current_workspace_id(), + name=body.name, + created_by=str(request.state.user.id), + role=body.role, + identity_mode=body.identity_mode, + ) + + +@router.get("", dependencies=[Depends(require_workspace_admin)]) +async def list_service_accounts(repo: ServiceAccountRepository = Depends(get_service_account_repo)): + return await repo.list_by_workspace(_current_workspace_id()) + + +@router.patch("/{sa_id}", dependencies=[Depends(require_workspace_admin)]) +async def update_service_account( + sa_id: str, + body: UpdateServiceAccountRequest, + repo: ServiceAccountRepository = Depends(get_service_account_repo), +): + sa = await repo.get(sa_id) + if sa is None or sa["workspace_id"] != _current_workspace_id(): + raise HTTPException(status_code=404, detail="service account not found") + await repo.update_status(sa_id, body.status) + return await repo.get(sa_id) diff --git a/backend/tests/test_service_accounts_router.py b/backend/tests/test_service_accounts_router.py new file mode 100644 index 00000000..68dc7280 --- /dev/null +++ b/backend/tests/test_service_accounts_router.py @@ -0,0 +1,123 @@ +"""service-accounts router tests (Stage 1 PR4).""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def _init_db(tmp_path): + from deerflow.persistence.engine import get_session_factory, init_engine + from deerflow.persistence.user.model import UserRow + from deerflow.persistence.workspace.model import WorkspaceRow + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + async with sf() as session: + session.add(UserRow(id="u-alice", email="alice@example.com")) + await session.commit() + async with sf() as session: + session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice")) + await session.commit() + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +def _make_app(*, role="owner", user_id="u-alice", workspace_id="w-1"): + """App that stamps a fixed principal + workspace, then mounts the router. + + A tiny inline middleware substitutes for AuthMiddleware so the test + controls role/user/workspace directly. + """ + from fastapi import FastAPI, Request + from starlette.middleware.base import BaseHTTPMiddleware + + from app.gateway.authz import _ALL_PERMISSIONS, AuthContext + from app.gateway.routers import service_accounts + from deerflow.runtime.user_context import reset_current_user, set_current_user + from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace + + class _Stamp(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + user = type("U", (), {"id": user_id, "is_service_account": False})() + ws = type("W", (), {"id": workspace_id, "role": role})() + request.state.user = user + request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) + ut = set_current_user(user) + wt = set_current_workspace(ws) + try: + return await call_next(request) + finally: + reset_current_workspace(wt) + reset_current_user(ut) + + app = FastAPI() + app.add_middleware(_Stamp) + app.include_router(service_accounts.router) + return app + + +async def test_owner_creates_and_lists_sa(tmp_path): + await _init_db(tmp_path) + try: + client = TestClient(_make_app(role="owner")) + r = client.post("/api/v1/service-accounts", json={"name": "ci-bot"}) + assert r.status_code == 201, r.text + sa = r.json() + assert sa["name"] == "ci-bot" + assert sa["workspace_id"] == "w-1" + assert sa["created_by"] == "u-alice" + + lst = client.get("/api/v1/service-accounts") + assert lst.status_code == 200 + assert [s["id"] for s in lst.json()] == [sa["id"]] + finally: + await _cleanup() + + +async def test_member_cannot_create_sa(tmp_path): + await _init_db(tmp_path) + try: + client = TestClient(_make_app(role="member")) + r = client.post("/api/v1/service-accounts", json={"name": "x"}) + assert r.status_code == 403 + finally: + await _cleanup() + + +async def test_patch_status_suspend(tmp_path): + await _init_db(tmp_path) + try: + client = TestClient(_make_app(role="admin")) + sa = client.post("/api/v1/service-accounts", json={"name": "bot"}).json() + r = client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"}) + assert r.status_code == 200 + assert r.json()["status"] == "suspended" + finally: + await _cleanup() + + +async def test_patch_other_workspace_sa_404(tmp_path): + await _init_db(tmp_path) + try: + # SA created in w-1 + owner_client = TestClient(_make_app(role="owner", workspace_id="w-1")) + sa = owner_client.post("/api/v1/service-accounts", json={"name": "bot"}).json() + # Caller in a different workspace tries to patch it → 404 + other_client = TestClient(_make_app(role="owner", workspace_id="w-2")) + r = other_client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"}) + assert r.status_code == 404 + finally: + await _cleanup()