Skip to content

symfonic.platform.posture

posture

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.",
        )