diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 103c1be5..4b559e03 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -13,6 +13,7 @@ from app.gateway.csrf_middleware import CSRFMiddleware from app.gateway.deps import langgraph_runtime from app.gateway.routers import ( agents, + api_keys, artifacts, assistants_compat, auth, @@ -415,6 +416,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Service Accounts API is mounted at /api/v1/service-accounts app.include_router(service_accounts.router) + # API Keys API is mounted at /api/v1/api-keys + app.include_router(api_keys.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/api_keys.py b/backend/app/gateway/routers/api_keys.py new file mode 100644 index 00000000..f890fd54 --- /dev/null +++ b/backend/app/gateway/routers/api_keys.py @@ -0,0 +1,108 @@ +"""API key management endpoints (Stage 1 PR4). + +Owner/admin mint / list / revoke API keys for a service account in the +caller's workspace. The plaintext token is returned exactly once, at +create time; list responses never include plaintext or the hash. The +target service account must belong to the caller's workspace, else 404. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, Field + +from app.gateway.authz import require_workspace_admin +from deerflow.auth.tokens import generate_api_key +from deerflow.persistence.api_key import ApiKeyRepository +from deerflow.persistence.service_account import ServiceAccountRepository +from deerflow.runtime.workspace_context import get_current_workspace + +router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"]) + + +class CreateApiKeyRequest(BaseModel): + service_account_id: str + name: str = Field(..., min_length=1, max_length=64) + scopes: str = Field(default="") + env: Literal["live", "test"] = "live" + expires_at: datetime | None = None + + +def get_api_key_repo() -> ApiKeyRepository: + 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 ApiKeyRepository(sf) + + +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) + + +async def _require_sa_in_workspace(sa_id: str, sa_repo: ServiceAccountRepository) -> dict: + sa = await sa_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") + return sa + + +@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)]) +async def create_api_key( + body: CreateApiKeyRequest, + request: Request, + key_repo: ApiKeyRepository = Depends(get_api_key_repo), + sa_repo: ServiceAccountRepository = Depends(get_service_account_repo), +): + await _require_sa_in_workspace(body.service_account_id, sa_repo) + gen = generate_api_key(body.env) + created = await key_repo.create( + service_account_id=body.service_account_id, + key_prefix=gen.prefix, + key_hash=gen.key_hash, + name=body.name, + scopes=body.scopes, + expires_at=body.expires_at, + ) + # plaintext returned exactly once; never persisted, never re-served. + return {**created, "plaintext": gen.plaintext} + + +@router.get("", dependencies=[Depends(require_workspace_admin)]) +async def list_api_keys( + service_account_id: str, + key_repo: ApiKeyRepository = Depends(get_api_key_repo), + sa_repo: ServiceAccountRepository = Depends(get_service_account_repo), +): + await _require_sa_in_workspace(service_account_id, sa_repo) + return await key_repo.list_by_service_account(service_account_id) + + +@router.delete("/{key_id}", status_code=204, dependencies=[Depends(require_workspace_admin)]) +async def revoke_api_key( + key_id: str, + key_repo: ApiKeyRepository = Depends(get_api_key_repo), + sa_repo: ServiceAccountRepository = Depends(get_service_account_repo), +): + key = await key_repo.get(key_id) + if key is None: + raise HTTPException(status_code=404, detail="api key not found") + await _require_sa_in_workspace(key["service_account_id"], sa_repo) + await key_repo.revoke(key_id) + return Response(status_code=204) diff --git a/backend/tests/test_api_keys_router.py b/backend/tests/test_api_keys_router.py new file mode 100644 index 00000000..a5c6810a --- /dev/null +++ b/backend/tests/test_api_keys_router.py @@ -0,0 +1,125 @@ +"""api-keys router tests (Stage 1 PR4). + +plaintext is returned exactly once at create time; never on list. +""" + +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_with_sa(tmp_path, *, sa_id="sa-1", workspace_id="w-1"): + from deerflow.persistence.engine import get_session_factory, init_engine + from deerflow.persistence.service_account.model import ServiceAccountRow + 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=workspace_id, name="WS", slug=f"ws-{workspace_id}", owner_id="u-alice")) + await session.commit() + async with sf() as session: + session.add(ServiceAccountRow(id=sa_id, workspace_id=workspace_id, name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice")) + await session.commit() + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +def _make_app(*, role="owner", workspace_id="w-1"): + from fastapi import FastAPI, Request + from starlette.middleware.base import BaseHTTPMiddleware + + from app.gateway.authz import _ALL_PERMISSIONS, AuthContext + from app.gateway.routers import api_keys + 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": "u-alice", "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(api_keys.router) + return app + + +async def test_create_returns_plaintext_once(tmp_path): + await _init_db_with_sa(tmp_path) + try: + client = TestClient(_make_app()) + r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "ci", "scopes": "threads:read"}) + assert r.status_code == 201, r.text + body = r.json() + assert body["plaintext"].startswith("dfk_live_") + assert body["key_prefix"] == body["plaintext"][:16] + + lst = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}) + assert lst.status_code == 200 + rows = lst.json() + assert len(rows) == 1 + assert "plaintext" not in rows[0] + assert "key_hash" not in rows[0] + finally: + await _cleanup() + + +async def test_create_for_other_workspace_sa_404(tmp_path): + await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1") + try: + # Caller is in w-2 but targets sa-1 which lives in w-1 → 404. + client = TestClient(_make_app(workspace_id="w-2")) + r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""}) + assert r.status_code == 404 + finally: + await _cleanup() + + +async def test_revoke_key(tmp_path): + await _init_db_with_sa(tmp_path) + try: + client = TestClient(_make_app()) + created = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json() + r = client.delete(f"/api/v1/api-keys/{created['id']}") + assert r.status_code == 204 + rows = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}).json() + assert rows[0]["revoked_at"] is not None + finally: + await _cleanup() + + +async def test_member_cannot_create_key(tmp_path): + await _init_db_with_sa(tmp_path) + try: + client = TestClient(_make_app(role="member")) + r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""}) + assert r.status_code == 403 + finally: + await _cleanup()