feat(auth): add API-key authentication and tenant resolution

This commit is contained in:
2026-08-19 15:00:13 +03:30
parent 3803d9c79a
commit e97ce6e5f3
7 changed files with 191 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
"""API-key authentication and tenant resolution (ADR-0008).
`resolve_auth_context` is the entry point: it takes a bearer token and
returns a trusted `AuthContext`. Everything downstream of the FastAPI
boundary receives `tenant_id` only through that context — never from a
request body, query string, or object metadata.
"""
from src.application.auth.context import AuthContext
from src.application.auth.errors import (
AuthError,
InvalidApiKeyError,
MissingScopeError,
TenantInactiveError,
)
from src.application.auth.keys import generate_api_key, hash_secret, verify_secret
from src.application.auth.service import resolve_auth_context
__all__ = [
"AuthContext",
"AuthError",
"InvalidApiKeyError",
"MissingScopeError",
"TenantInactiveError",
"generate_api_key",
"hash_secret",
"resolve_auth_context",
"verify_secret",
]

View File

@@ -0,0 +1,16 @@
"""The trusted request-scoped auth/tenant context (ADR-0008)."""
import uuid
from dataclasses import dataclass
@dataclass(frozen=True)
class AuthContext:
tenant_id: uuid.UUID
tenant_slug: str
api_key_id: uuid.UUID
scopes: frozenset[str]
actor_type: str
def has_scope(self, scope: str) -> bool:
return scope in self.scopes or "admin" in self.scopes

View File

@@ -0,0 +1,22 @@
"""Auth failures (ADR-0008). No HTTP knowledge here — `src/api/errors.py` maps
these to status codes.
"""
class AuthError(Exception):
"""Base class for auth failures."""
class InvalidApiKeyError(AuthError):
"""The bearer token is missing, malformed, unknown, revoked, or expired.
Maps to `401`.
"""
class TenantInactiveError(AuthError):
"""The key's tenant is suspended or deleted. Maps to `401`."""
class MissingScopeError(AuthError):
"""The key is valid but lacks a scope the route requires. Maps to `403`."""

View File

@@ -0,0 +1,39 @@
"""API-key generation and hashing (ADR-0008, ADR-0009).
Keys are `sk_{prefix}_{secret}`. `prefix` is non-secret and indexed
(`api_keys.key_prefix`); `secret` is 256 bits of `secrets.token_urlsafe`
entropy, stored only as a SHA-256 hash. A random 256-bit secret does not
benefit from a slow password-hashing KDF the way a human-chosen password
does — the cost that defends against dictionary/brute-force guessing over a
low-entropy input has nothing to defend here, and would only tax every
request. Comparison is constant-time to avoid a hash-timing oracle.
"""
import hashlib
import hmac
import secrets
_PREFIX_LENGTH = 16
def generate_api_key() -> tuple[str, str, str]:
"""Return `(key_prefix, secret, full_key)` for a newly issued key."""
key_prefix = secrets.token_hex(_PREFIX_LENGTH // 2)
secret = secrets.token_urlsafe(32)
return key_prefix, secret, f"sk_{key_prefix}_{secret}"
def parse_api_key(full_key: str) -> tuple[str, str] | None:
"""Return `(key_prefix, secret)`, or `None` if the token is malformed."""
parts = full_key.split("_", 2)
if len(parts) != 3 or parts[0] != "sk" or not parts[1] or not parts[2]:
return None
return parts[1], parts[2]
def hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def verify_secret(secret: str, key_hash: str) -> bool:
return hmac.compare_digest(hash_secret(secret), key_hash)

View File

@@ -0,0 +1,47 @@
"""Resolve a bearer token to a trusted `AuthContext` (ADR-0008).
This opens and releases its own session rather than borrowing a
request-scoped one, so auth resolution never pins a pool connection across
the rest of the request — including the ADR-0017 ingestion work phase, which
must run with no Postgres session held open at all.
"""
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.context import AuthContext
from src.application.auth.errors import InvalidApiKeyError, TenantInactiveError
from src.application.auth.keys import parse_api_key, verify_secret
from src.infrastructure.postgres.repositories import api_keys as api_keys_repo
from src.infrastructure.postgres.repositories import tenants as tenants_repo
async def resolve_auth_context(
sessionmaker: async_sessionmaker[AsyncSession], bearer_token: str
) -> AuthContext:
parsed = parse_api_key(bearer_token)
if parsed is None:
raise InvalidApiKeyError("malformed API key")
key_prefix, secret = parsed
async with sessionmaker() as session:
api_key = await api_keys_repo.get_by_prefix(session, key_prefix)
if api_key is None or not verify_secret(secret, api_key.key_hash):
raise InvalidApiKeyError("unknown API key")
if api_key.status != "active":
raise InvalidApiKeyError(f"API key is {api_key.status}")
if api_key.expires_at is not None and api_key.expires_at <= datetime.now(UTC):
raise InvalidApiKeyError("API key has expired")
tenant = await tenants_repo.get_by_id(session, api_key.tenant_id)
if tenant is None or tenant.status != "active":
raise TenantInactiveError("tenant is not active")
return AuthContext(
tenant_id=tenant.id,
tenant_slug=tenant.slug,
api_key_id=api_key.id,
scopes=frozenset(api_key.scopes),
actor_type=api_key.actor_type,
)