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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user