feat(auth): add require_caller — static service keys before any JWT (T-1)

core-api had no service-key auth: AuthService.validate_api_key was never
called, api_keys stayed empty, and CORE_API_KEY was read by nothing. This
is the first thing that reads a service key.

require_caller(*allowed, identity_required=False) resolves, in order:
office_service_key -> tatlock_service_key -> scheduler_service_key ->
the existing get_current_user() JWT path. Each static key is compared
with hmac.compare_digest, gated on the configured key being non-empty so
an unset key can never match. A disallowed kind is 403 forbidden.

The office caller's identity comes from X-Office-Identity: required on
writes (400 identity_required if missing), validated against
^[a-z0-9][a-z0-9-]{0,62}$ and against the reserved/group- set (400
identity_invalid). Other kinds carry a fixed identity and ignore the
header.

New settings (src/shared/config.py, default ""): office_service_key,
tatlock_service_key, scheduler_service_key; and book_timezone
("Europe/Amsterdam") for the Book's due/remind handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:14:15 +02:00
co-authored by Claude Sonnet 5
parent 0df3fdddcb
commit 04ec54b635
4 changed files with 330 additions and 0 deletions
+4
View File
@@ -20,6 +20,7 @@ from src.domains.auth.oidc import (
)
from src.domains.auth.service import AuthService, get_auth_service
from src.domains.auth.controller import auth_controller
from src.domains.auth.callers import Caller, require_caller
from src.domains.auth.models import (
User,
Role,
@@ -50,6 +51,9 @@ __all__ = [
"get_auth_service",
# Controller
"auth_controller",
# Callers (T-1)
"Caller",
"require_caller",
# Models
"User",
"Role",
+128
View File
@@ -0,0 +1,128 @@
"""
Caller Authentication
core-api has no service-key auth today: AuthService.validate_api_key is
never called in any route, api_keys stays empty, and `CORE_API_KEY` is read
by nothing. This module is the first thing that reads it.
Resolution order, static keys before any JWT parsing:
1. Bearer == office_service_key -> kind "office"
2. Bearer == tatlock_service_key -> kind "tatlock"
3. Bearer == scheduler_service_key -> kind "scheduler"
4. Otherwise -> get_current_user() -> kind "user"
Each static key is matched with hmac.compare_digest, and an unconfigured
(empty-string) key never matches — checked explicitly rather than trusting
compare_digest("", "") to be the right thing to compare, which would make an
empty configured key match an empty bearer.
"""
import hmac
import re
from dataclasses import dataclass
from typing import Callable, Literal, Optional
from fastapi import Depends, HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials
from src.domains.auth.oidc import get_current_user, security
from src.shared.config import get_settings
from src.shared.logging import get_logger
logger = get_logger(__name__)
CallerKind = Literal["office", "tatlock", "scheduler", "user"]
# Reserved identities: the system nouns, never a caller's own identity.
RESERVED_IDENTITIES = {"book", "office", "scheduler", "tatlock"}
# ^[a-z0-9][a-z0-9-]{0,62}$, and additionally not a "group-*" shape (checked
# separately below, since the character class alone can't exclude a prefix).
_IDENTITY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
@dataclass(frozen=True)
class Caller:
"""Who is calling, resolved once per request."""
kind: CallerKind
identity: str # stamped on every write and event
client: Optional[str] # X-Office-Client, informational
def _identity_error(code: str) -> HTTPException:
return HTTPException(status_code=400, detail={"code": code})
def _validate_identity(identity: str) -> None:
"""Raise 400 identity_invalid for a malformed or reserved identity."""
if not _IDENTITY_RE.match(identity):
raise _identity_error("identity_invalid")
if identity.startswith("group-") or identity in RESERVED_IDENTITIES:
raise _identity_error("identity_invalid")
def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Callable:
"""
Build a FastAPI dependency that resolves the caller and enforces it is
one of `allowed`.
Args:
allowed: caller kinds permitted on this route.
identity_required: for an "office" caller, whether X-Office-Identity
must be present (true on writes). Other kinds carry a fixed
identity and ignore this.
"""
async def dependency(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> Caller:
settings = get_settings()
token = credentials.credentials if credentials else None
client = request.headers.get("X-Office-Client")
caller: Optional[Caller] = None
if (
token
and settings.office_service_key
and hmac.compare_digest(token, settings.office_service_key)
):
identity_header = request.headers.get("X-Office-Identity")
if not identity_header:
if identity_required:
raise _identity_error("identity_required")
identity = ""
else:
_validate_identity(identity_header)
identity = identity_header
caller = Caller(kind="office", identity=identity, client=client)
elif (
token
and settings.tatlock_service_key
and hmac.compare_digest(token, settings.tatlock_service_key)
):
caller = Caller(kind="tatlock", identity="tatlock", client=client)
elif (
token
and settings.scheduler_service_key
and hmac.compare_digest(token, settings.scheduler_service_key)
):
caller = Caller(kind="scheduler", identity="scheduler", client=client)
else:
# No static key matched (or none configured) — fall through to
# the existing OIDC/JWT path. A static key never reaches here.
user = await get_current_user(credentials)
identity = (user or {}).get("preferred_username") or "user"
caller = Caller(kind="user", identity=identity, client=client)
if caller.kind not in allowed:
logger.warning(f"Caller kind '{caller.kind}' forbidden on this route (allowed: {allowed})")
raise HTTPException(status_code=403, detail={"code": "forbidden"})
return caller
return dependency
+9
View File
@@ -104,6 +104,15 @@ class Settings(BaseSettings):
authentik_username: str = ""
authentik_password: str = ""
# Caller auth — static service keys (T-1). Empty by default so an unset
# key can never match an empty Bearer token (see src.domains.auth.callers).
office_service_key: str = ""
tatlock_service_key: str = ""
scheduler_service_key: str = ""
# The Book (T-1) — local time for due/remind/recurrence handling.
book_timezone: str = "Europe/Amsterdam"
class Config:
env_file = ".env"
case_sensitive = False