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,
|
||||||
|
)
|
||||||
0
tests/unit/application/auth/__init__.py
Normal file
0
tests/unit/application/auth/__init__.py
Normal file
38
tests/unit/application/auth/test_keys.py
Normal file
38
tests/unit/application/auth/test_keys.py
Normal file
@@ -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"))
|
||||||
Reference in New Issue
Block a user