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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user