SCOPE-5 — the production gate, and the dev-mode posture it cannot cover.
The shipped fail-fast is preserved verbatim in behaviour: production detected
via SYMFONIC_ENV / APP_ENV / ENVIRONMENT / NODE_ENV ∈
{production, prod} -- any one of them -- with no verifier registered
raises. The insecure override is the resolver's own
allow_insecure_prod=True, which downgrades it to a CRITICAL log; the
ALLOW_INSECURE_PROD environment variable is not read here. What moves is
where it runs: host assembly, as part of the readiness decision (HOST-5/-6),
instead of as a side effect of building a router.
What is new is :class:AuthPosture, and it exists because T4.1.1 recorded a
defect and left it open. In dev mode the missing-verifier warning is one-shot:
after the first request, a deployment whose environment variable is spelled
prd or live — not on the recognised list — trusts X-Tenant-ID in
silence forever. A log line that fires once is not a monitoring surface. The
posture is a value a readiness endpoint or a diagnostic can read on every poll,
and it counts the requests served on trust so the number is not zero when
somebody finally looks.
AuthPosture
dataclass
AuthPosture(environment: str, production: bool, verifier_registered: bool, insecure_override: bool, requests_served_untrusted: int = 0)
Continuously observable answer to "is this host trusting headers?".
Every field is something an operator would want on a dashboard, and
requests_served_untrusted is the one that turns a silent misconfiguration
into a rising number.
detect_environment
detect_environment(environ: dict[str, str] | None = None) -> str
The first recognised environment name, or "unknown".
environ is a parameter rather than a read of os.environ at call
time so a host can be constructed under a configuration it was given —
HOST-1 reads configuration once, and a component that re-reads the process
environment on its own is a second configuration source.
Source code in src/symfonic/platform/posture.py
| def detect_environment(environ: dict[str, str] | None = None) -> str:
"""The first recognised environment name, or ``"unknown"``.
``environ`` is a parameter rather than a read of ``os.environ`` at call
time so a host can be constructed under a configuration it was *given* —
HOST-1 reads configuration once, and a component that re-reads the process
environment on its own is a second configuration source.
"""
source = os.environ if environ is None else environ
values = [source.get(var, "").strip().lower() for var in PRODUCTION_ENV_VARS]
# Any variable naming production wins, as the legacy is_production_env did:
# an earlier ``SYMFONIC_ENV=staging`` must not mask ``APP_ENV=production``.
production = [value for value in values if value in PRODUCTION_VALUES]
return next(iter(production or [value for value in values if value]), "unknown")
|
production_auth_gate
production_auth_gate(resolver: object, *, force_production: bool = False) -> None
HOST-5/HOST-6 — fail closed at startup, or log CRITICAL and continue.
Takes the resolver structurally (anything with posture()) so host
assembly can gate an adopter's own resolver, not only the shipped one. A
host that cannot build its auth path does not start in a degraded
"allow everything" mode.
force_production lets an HTTP boundary honor process production signals
even if a resolver's own environment field disagrees.
Source code in src/symfonic/platform/posture.py
| def production_auth_gate(resolver: object, *, force_production: bool = False) -> None:
"""HOST-5/HOST-6 — fail closed at startup, or log CRITICAL and continue.
Takes the resolver structurally (anything with ``posture()``) so host
assembly can gate an adopter's own resolver, not only the shipped one. A
host that cannot build its auth path does not start in a degraded
"allow everything" mode.
``force_production`` lets an HTTP boundary honor process production signals
even if a resolver's own environment field disagrees.
"""
posture_of = getattr(resolver, "posture", None)
if not callable(posture_of):
raise RuntimeError(
"the production auth gate needs a resolver that can report its "
"posture(); a resolver that cannot describe whether it authenticates "
"cannot be gated, and an ungateable auth path is not a safe default"
)
try:
posture: AuthPosture = posture_of()
fields = (posture.production, posture.insecure_override, posture.verifier_registered)
except Exception as exc: # noqa: BLE001 - an unreadable posture attests nothing
raise RuntimeError(
f"the production auth gate could not read {type(resolver).__name__}.posture(): "
"posture() must return symfonic.platform.AuthPosture (verifier_registered="
"True once a tenant-auth verifier is wired). A posture that raises or is "
"prose attests nothing, so it is not accepted."
) from exc
del fields
if not (posture.production or force_production):
return
if posture.insecure_override:
logger.critical(
"ALLOW_INSECURE_PROD detected — tenant auth disabled in production. "
"Data WILL leak across tenants. Register a verifier immediately.",
)
return
if not posture.verifier_registered:
raise RuntimeError(
"Production environment detected but no tenant auth verifier is "
"registered. Construct the ScopeResolver with a verifier before "
"mounting routers, or accept the risk explicitly with "
"HeaderScopeResolver(allow_insecure_prod=True) (NOT RECOMMENDED). "
"The ALLOW_INSECURE_PROD environment variable is not read by this gate.",
)
|