diff --git a/src/application/auth/__init__.py b/src/application/auth/__init__.py new file mode 100644 index 0000000..2d03afb --- /dev/null +++ b/src/application/auth/__init__.py @@ -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", +] diff --git a/src/application/auth/context.py b/src/application/auth/context.py new file mode 100644 index 0000000..37dbf7f --- /dev/null +++ b/src/application/auth/context.py @@ -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 diff --git a/src/application/auth/errors.py b/src/application/auth/errors.py new file mode 100644 index 0000000..700b9cf --- /dev/null +++ b/src/application/auth/errors.py @@ -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`.""" diff --git a/src/application/auth/keys.py b/src/application/auth/keys.py new file mode 100644 index 0000000..45aeb1b --- /dev/null +++ b/src/application/auth/keys.py @@ -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) diff --git a/src/application/auth/service.py b/src/application/auth/service.py new file mode 100644 index 0000000..93bdc36 --- /dev/null +++ b/src/application/auth/service.py @@ -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, + ) diff --git a/tests/unit/application/auth/__init__.py b/tests/unit/application/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/application/auth/test_keys.py b/tests/unit/application/auth/test_keys.py new file mode 100644 index 0000000..9ce7cdb --- /dev/null +++ b/tests/unit/application/auth/test_keys.py @@ -0,0 +1,38 @@ +import pytest + +from src.application.auth.keys import generate_api_key, hash_secret, parse_api_key, verify_secret + +pytestmark = pytest.mark.unit + + +def test_generate_api_key_round_trips_through_parse() -> None: + key_prefix, secret, full_key = generate_api_key() + + parsed = parse_api_key(full_key) + + assert parsed == (key_prefix, secret) + + +def test_generate_api_key_produces_unique_secrets() -> None: + _, secret_a, _ = generate_api_key() + _, secret_b, _ = generate_api_key() + + assert secret_a != secret_b + + +@pytest.mark.parametrize( + "malformed", + ["", "sk_onlyprefix", "wrongscheme_prefix_secret", "sk__secret", "sk_prefix_"], +) +def test_parse_api_key_rejects_malformed_input(malformed: str) -> None: + assert parse_api_key(malformed) is None + + +def test_verify_secret_accepts_matching_secret() -> None: + _, secret, _ = generate_api_key() + + assert verify_secret(secret, hash_secret(secret)) + + +def test_verify_secret_rejects_wrong_secret() -> None: + assert not verify_secret("wrong-secret", hash_secret("correct-secret"))