feat(auth): T-4 — the caller carries consumer and tenant from the Office

The Office (workspace D-73) now signs every visitor in through Authentik
with one OAuth client per consumer, and every token carries a signed
tatlock_user claim. Caller gains consumer (X-Office-Consumer, the
Authentik application slug) and tenant (X-Office-Tenant, the
tatlock_user claim). For kind "office" both are required on every call,
reads included — missing is 400 consumer_required/tenant_required,
malformed is 400 consumer_invalid/tenant_invalid. Every other kind
ignores both headers outright: tatlock and scheduler carry no tenant,
and a "user" caller's tenant comes only from their own JWT's
tatlock_user claim, never from a header — a header must not be able to
put a non-Office caller into a tenant.

X-Office-Client (informational) is retired in favour of
X-Office-Consumer. book_events.client keeps its name (no migration this
round) and now records the consumer instead.

Book routes do not start filtering by tenant this round — that is
core-api T-2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 13:43:36 +02:00
co-authored by Claude Sonnet 5
parent 1da057bea6
commit 0453d08206
4 changed files with 414 additions and 38 deletions
+65 -12
View File
@@ -15,6 +15,25 @@ 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.
core-api T-4 (workspace D-73): the Office now signs every visitor in through
Authentik with one OAuth client per consumer, and every token carries a
signed `tatlock_user` claim. The Office verifies the token and forwards the
result in two headers, trusted only alongside the office service key:
- `X-Office-Consumer` — the Authentik application slug the token came from
(`office-claude`, `office-gemini`, ...).
- `X-Office-Tenant` — the token's `tatlock_user` claim (the tatlock tenant).
For kind "office" both are required on **every** call, reads included — the
tenant scopes reads too. For every other kind they are ignored outright:
"tatlock" and "scheduler" carry no tenant (`None`); a "user" caller gets its
tenant from its own JWT's `tatlock_user` claim if it has one, else `None` —
never from a header, so a header can never put a non-Office caller into a
tenant.
`X-Office-Client` (informational, pre-T-4) is retired in favour of
`X-Office-Consumer`; nothing reads it any more.
"""
import hmac
import re
@@ -39,26 +58,54 @@ RESERVED_IDENTITIES = {"book", "office", "scheduler", "tatlock"}
# 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}$")
# X-Office-Consumer: the Authentik application slug, same shape as an identity.
_CONSUMER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
# X-Office-Tenant: tatlock's own tenant ids contain underscores (e.g.
# `llm_tester`), so the identity/consumer shape doesn't fit.
_TENANT_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
identity: str # stamped on every write and event
consumer: Optional[str] # X-Office-Consumer; office only, else None
tenant: Optional[str] # the tatlock tenant; office header or a user's own JWT claim
def _identity_error(code: str) -> HTTPException:
def _bad_request(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")
raise _bad_request("identity_invalid")
if identity.startswith("group-") or identity in RESERVED_IDENTITIES:
raise _identity_error("identity_invalid")
raise _bad_request("identity_invalid")
def _require_office_consumer(request: Request) -> str:
"""X-Office-Consumer is required on every office call; raise if absent/malformed."""
value = request.headers.get("X-Office-Consumer")
if not value:
raise _bad_request("consumer_required")
if not _CONSUMER_RE.match(value):
raise _bad_request("consumer_invalid")
return value
def _require_office_tenant(request: Request) -> str:
"""X-Office-Tenant is required on every office call, reads included."""
value = request.headers.get("X-Office-Tenant")
if not value:
raise _bad_request("tenant_required")
if not _TENANT_RE.match(value):
raise _bad_request("tenant_invalid")
return value
def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Callable:
@@ -70,7 +117,8 @@ def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Cal
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.
identity and ignore this. Unrelated to consumer/tenant, which an
"office" caller must carry on every call regardless of this flag.
"""
async def dependency(
@@ -79,7 +127,6 @@ def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Cal
) -> Caller:
settings = get_settings()
token = credentials.credentials if credentials else None
client = request.headers.get("X-Office-Client")
caller: Optional[Caller] = None
@@ -88,36 +135,42 @@ def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Cal
and settings.office_service_key
and hmac.compare_digest(token, settings.office_service_key)
):
consumer = _require_office_consumer(request)
tenant = _require_office_tenant(request)
identity_header = request.headers.get("X-Office-Identity")
if not identity_header:
if identity_required:
raise _identity_error("identity_required")
raise _bad_request("identity_required")
identity = ""
else:
_validate_identity(identity_header)
identity = identity_header
caller = Caller(kind="office", identity=identity, client=client)
caller = Caller(kind="office", identity=identity, consumer=consumer, tenant=tenant)
elif (
token
and settings.tatlock_service_key
and hmac.compare_digest(token, settings.tatlock_service_key)
):
caller = Caller(kind="tatlock", identity="tatlock", client=client)
caller = Caller(kind="tatlock", identity="tatlock", consumer=None, tenant=None)
elif (
token
and settings.scheduler_service_key
and hmac.compare_digest(token, settings.scheduler_service_key)
):
caller = Caller(kind="scheduler", identity="scheduler", client=client)
caller = Caller(kind="scheduler", identity="scheduler", consumer=None, tenant=None)
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)
# The tenant comes only from the JWT's own claim — never from a
# header, which would let any caller spoof another tenant.
tenant = (user or {}).get("tatlock_user") or None
caller = Caller(kind="user", identity=identity, consumer=None, tenant=tenant)
if caller.kind not in allowed:
logger.warning(f"Caller kind '{caller.kind}' forbidden on this route (allowed: {allowed})")
+1 -1
View File
@@ -70,7 +70,7 @@ class BookService:
event = BookEvent(
record_id=record_id,
identity=caller.identity,
client=caller.client,
client=caller.consumer, # column name unchanged (no migration this round); T-4 retires X-Office-Client and records the consumer here instead
action=action,
diff=diff,
)