938c2eb0be
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
"""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, 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,
|
|
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
|
|
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
|
):
|
|
sa = await _require_sa_in_workspace(body.service_account_id, sa_repo)
|
|
if sa["status"] != "active":
|
|
raise HTTPException(status_code=409, detail="service account is not active")
|
|
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")
|
|
sa = await sa_repo.get(key["service_account_id"])
|
|
if sa is None or sa["workspace_id"] != _current_workspace_id():
|
|
raise HTTPException(status_code=404, detail="api key not found")
|
|
await key_repo.revoke(key_id)
|
|
return Response(status_code=204)
|