feat(gateway): api-keys management endpoints with one-time plaintext (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from app.gateway.csrf_middleware import CSRFMiddleware
|
|||||||
from app.gateway.deps import langgraph_runtime
|
from app.gateway.deps import langgraph_runtime
|
||||||
from app.gateway.routers import (
|
from app.gateway.routers import (
|
||||||
agents,
|
agents,
|
||||||
|
api_keys,
|
||||||
artifacts,
|
artifacts,
|
||||||
assistants_compat,
|
assistants_compat,
|
||||||
auth,
|
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
|
# Service Accounts API is mounted at /api/v1/service-accounts
|
||||||
app.include_router(service_accounts.router)
|
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
|
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||||
app.include_router(feedback.router)
|
app.include_router(feedback.router)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user