feat(auth): add API-key authentication and tenant resolution
This commit is contained in:
29
src/application/auth/__init__.py
Normal file
29
src/application/auth/__init__.py
Normal 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",
|
||||
]
|
||||
16
src/application/auth/context.py
Normal file
16
src/application/auth/context.py
Normal 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
|
||||
22
src/application/auth/errors.py
Normal file
22
src/application/auth/errors.py
Normal 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`."""
|
||||
39
src/application/auth/keys.py
Normal file
39
src/application/auth/keys.py
Normal 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)
|
||||
47
src/application/auth/service.py
Normal file
47
src/application/auth/service.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user