100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""Caller identity resolved from the trusted hop's headers.
|
|
|
|
The chat router, the Telegram relay and the Worker are the only callers. Each
|
|
asserts identity in headers; the request body is never trusted for identity.
|
|
Router, relay, and worker callers must each present their distinct shared key,
|
|
compared in constant time against an inline or projected secret value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
from hux.errors import Unauthorized
|
|
|
|
HEADER_SLOT = "X-Hermes-Tenant-Identity"
|
|
HEADER_SUBJECT = "X-Hux-Subject"
|
|
HEADER_SURFACE = "X-Hux-Surface"
|
|
HEADER_TRUST = "X-Hux-Trust"
|
|
HEADER_KEY = "X-Hux-Relay-Key"
|
|
|
|
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
|
|
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
|
|
SURFACES = ("chat", "worker", "telegram", "voice", "api")
|
|
TRUSTS = ("router", "relay", "worker")
|
|
KEY_ENV = {"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"}
|
|
MAX_KEY_BYTES = 4096
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Identity:
|
|
"""Who is calling, on which surface, asserted by which trusted hop."""
|
|
|
|
tenant_slot: str
|
|
subject: str
|
|
surface: str
|
|
trust: str
|
|
|
|
def record(self) -> dict[str, str]:
|
|
"""Serialise as ``common.identity``."""
|
|
return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust}
|
|
|
|
|
|
def _header(headers: Mapping[str, str], name: str) -> str:
|
|
for key, value in headers.items():
|
|
if key.lower() == name.lower():
|
|
return value.strip()
|
|
return ""
|
|
|
|
|
|
def _expected_key(environ: Mapping[str, str], trust: str) -> str:
|
|
"""Read a caller key, preferring a bounded Kubernetes secret file."""
|
|
name = KEY_ENV[trust]
|
|
key_file = environ.get(f"{name}_FILE", "")
|
|
if key_file:
|
|
try:
|
|
data = Path(key_file).read_bytes()
|
|
except OSError:
|
|
return ""
|
|
if len(data) > MAX_KEY_BYTES:
|
|
return ""
|
|
try:
|
|
return data.decode("utf-8", errors="strict").strip()
|
|
except UnicodeDecodeError:
|
|
return ""
|
|
return environ.get(name, "")
|
|
|
|
|
|
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
|
|
"""Build an Identity from request headers or raise Unauthorized.
|
|
|
|
``trust`` defaults to ``router``; every trusted hop requires its distinct
|
|
shared key to be configured and presented.
|
|
"""
|
|
environ = os.environ if environ is None else environ
|
|
slot = _header(headers, HEADER_SLOT)
|
|
subject = _header(headers, HEADER_SUBJECT)
|
|
surface = _header(headers, HEADER_SURFACE) or "chat"
|
|
trust = _header(headers, HEADER_TRUST) or "router"
|
|
if not SLOT_RE.match(slot):
|
|
raise Unauthorized("missing or malformed tenant slot")
|
|
own_slot = environ.get("HUX_TENANT_SLOT", "")
|
|
if own_slot and slot != own_slot:
|
|
raise Unauthorized("tenant slot does not belong to this pod")
|
|
if not SUBJECT_RE.match(subject):
|
|
raise Unauthorized("missing or malformed subject")
|
|
if surface not in SURFACES or trust not in TRUSTS:
|
|
raise Unauthorized("unknown surface or trust")
|
|
expected = _expected_key(environ, trust)
|
|
presented = _header(headers, HEADER_KEY)
|
|
if not expected or not presented or not hmac.compare_digest(expected, presented):
|
|
raise Unauthorized(f"{trust} key missing or wrong")
|
|
if trust == "router" and surface == "worker":
|
|
raise Unauthorized("worker surface needs worker trust")
|
|
return Identity(slot, subject, surface, trust)
|