feat(auth): APIKeyAuthBackend resolves token to SA principal (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from deerflow.auth.tokens import hash_api_key, split_prefix
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,3 +32,66 @@ def parse_scopes(scopes: str) -> list[str]:
|
|||||||
Empty / whitespace-only segments are dropped.
|
Empty / whitespace-only segments are dropped.
|
||||||
"""
|
"""
|
||||||
return [s.strip() for s in scopes.split(",") if s.strip()]
|
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))
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import dataclasses
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.gateway.auth.api_key_backend import ServicePrincipal, parse_scopes
|
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():
|
def test_parse_scopes_splits_and_strips():
|
||||||
@@ -32,3 +35,93 @@ def test_service_principal_is_frozen():
|
|||||||
p = ServicePrincipal(id="sa-1")
|
p = ServicePrincipal(id="sa-1")
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
p.id = "other" # type: ignore[misc]
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user