From d9a86878f41501defbd94918db58861c32a67f91 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 11:40:27 +0800 Subject: [PATCH] feat(auth): APIKeyAuthBackend resolves token to SA principal (Stage 1 PR2) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/gateway/auth/api_key_backend.py | 65 ++++++++++++++ backend/tests/test_api_key_backend.py | 93 +++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/backend/app/gateway/auth/api_key_backend.py b/backend/app/gateway/auth/api_key_backend.py index ee3381b0..6afa9b55 100644 --- a/backend/app/gateway/auth/api_key_backend.py +++ b/backend/app/gateway/auth/api_key_backend.py @@ -11,6 +11,8 @@ from __future__ import annotations import logging from dataclasses import dataclass +from deerflow.auth.tokens import hash_api_key, split_prefix + logger = logging.getLogger(__name__) @@ -30,3 +32,66 @@ def parse_scopes(scopes: str) -> list[str]: Empty / whitespace-only segments are dropped. """ return [s.strip() for s in scopes.split(",") if s.strip()] + + +@dataclass(frozen=True) +class ApiKeyAuthResult: + """Everything ``AuthMiddleware`` needs to stamp request state + + contextvars from a verified API key.""" + + principal: ServicePrincipal + workspace_id: str + role: str + permissions: list[str] + + +class APIKeyAuthBackend: + def __init__(self, *, api_key_repo, service_account_repo, workspace_repo) -> None: + self._api_key_repo = api_key_repo + self._service_account_repo = service_account_repo + self._workspace_repo = workspace_repo + + async def authenticate(self, token: str) -> ApiKeyAuthResult | None: + """Resolve a plaintext token to an auth result, or None (→ 401).""" + # Look up by the indexed public prefix; the repo constant-time + # verifies the full hash. + key = await self._api_key_repo.get_active_by_hash(hash_api_key(token), key_prefix=split_prefix(token)) + if key is None: + return None + + sa = await self._service_account_repo.get_active(key["service_account_id"]) + if sa is None: + return None + + # SA is not a workspace *member* — bypass the membership filter + # with the documented user_id=None admin/migration path. + workspace = await self._workspace_repo.get(sa["workspace_id"], user_id=None) + if workspace is None or workspace["status"] != "active": + return None + + # Best-effort: never block the request if the timestamp write fails. + try: + await self._api_key_repo.touch_last_used(key["id"]) + except Exception: # noqa: BLE001 — best-effort, log and continue + logger.warning("touch_last_used failed for api_key %s", key["id"], exc_info=True) + + return ApiKeyAuthResult( + principal=ServicePrincipal(id=sa["id"]), + workspace_id=sa["workspace_id"], + role=sa["role"], + permissions=parse_scopes(key["scopes"]), + ) + + +def build_api_key_backend() -> APIKeyAuthBackend | None: + """Construct a backend from the global session factory, or None when + persistence is the in-memory backend (no DB → no API keys).""" + from deerflow.persistence.api_key import ApiKeyRepository + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.service_account import ServiceAccountRepository + from deerflow.persistence.workspace import WorkspaceRepository + + sf = get_session_factory() + if sf is None: + return None + return APIKeyAuthBackend(api_key_repo=ApiKeyRepository(sf), service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf)) diff --git a/backend/tests/test_api_key_backend.py b/backend/tests/test_api_key_backend.py index 573ab640..0c759508 100644 --- a/backend/tests/test_api_key_backend.py +++ b/backend/tests/test_api_key_backend.py @@ -7,6 +7,9 @@ import dataclasses import pytest from app.gateway.auth.api_key_backend import ServicePrincipal, parse_scopes +from deerflow.auth.tokens import generate_api_key + +pytestmark = pytest.mark.anyio def test_parse_scopes_splits_and_strips(): @@ -32,3 +35,93 @@ def test_service_principal_is_frozen(): p = ServicePrincipal(id="sa-1") with pytest.raises(dataclasses.FrozenInstanceError): p.id = "other" # type: ignore[misc] + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def _setup_backend(tmp_path, *, sa_status="active", ws_status="active", scopes="threads:read", expires_at=None, revoke=False): + from app.gateway.auth.api_key_backend import APIKeyAuthBackend + from deerflow.persistence.api_key import ApiKeyRepository + from deerflow.persistence.engine import get_session_factory, init_engine + from deerflow.persistence.service_account import ServiceAccountRepository + from deerflow.persistence.service_account.model import ServiceAccountRow + from deerflow.persistence.user.model import UserRow + from deerflow.persistence.workspace import WorkspaceRepository + 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", status=ws_status)) + await session.commit() + async with sf() as session: + session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status=sa_status, created_by="u-alice")) + await session.commit() + + api_key_repo = ApiKeyRepository(sf) + gen = generate_api_key("live") + created = await api_key_repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes, expires_at=expires_at) + if revoke: + await api_key_repo.revoke(created["id"]) + + backend = APIKeyAuthBackend(api_key_repo=api_key_repo, service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf)) + return backend, gen + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +async def test_authenticate_valid_key(tmp_path): + backend, gen = await _setup_backend(tmp_path, scopes="threads:read,threads:write") + try: + result = await backend.authenticate(gen.plaintext) + assert result is not None + assert result.principal.id == "sa-1" + assert result.principal.is_service_account is True + assert result.workspace_id == "w-1" + assert result.role == "member" + assert result.permissions == ["threads:read", "threads:write"] + finally: + await _cleanup() + + +async def test_authenticate_unknown_token_returns_none(tmp_path): + backend, _ = await _setup_backend(tmp_path) + try: + assert await backend.authenticate("dfk_live_doesnotexist000000000000") is None + finally: + await _cleanup() + + +async def test_authenticate_revoked_key_returns_none(tmp_path): + backend, gen = await _setup_backend(tmp_path, revoke=True) + try: + assert await backend.authenticate(gen.plaintext) is None + finally: + await _cleanup() + + +async def test_authenticate_suspended_sa_returns_none(tmp_path): + backend, gen = await _setup_backend(tmp_path, sa_status="suspended") + try: + assert await backend.authenticate(gen.plaintext) is None + finally: + await _cleanup() + + +async def test_authenticate_suspended_workspace_returns_none(tmp_path): + backend, gen = await _setup_backend(tmp_path, ws_status="suspended") + try: + assert await backend.authenticate(gen.plaintext) is None + finally: + await _cleanup()