Skip to content

symfonic.platform.scope

scope

SCOPE-1..8 — the one place a request becomes a principal.

The behaviour is the shipped get_tenant_scope: a missing X-Tenant-ID is 401, the verifier runs before the scope is built so an unauthorised caller never reaches a constructed scope, and a divergent sub-tenant/namespace pair is 400. What changes is ownership. Today the verifier is a module global — set_tenant_auth_verifier — which is a per-process singleton for a per-deployment fact: two agents mounted in one process share one verifier, and a test that registers one leaks it into the next. Here the resolver is an object the host constructs and injects (SCOPE-3, HOST-3), so two of them in one process are simply two objects.

HeaderScopeResolver

HeaderScopeResolver(*, verifier: TenantAuthVerifier | None = None, environment: str | None = None, allow_insecure_prod: bool = False, environ: dict[str, str] | None = None)

Registry row 16, default implementation: headers plus an injected verifier.

X-Tenant-ID on its own is untrusted input — the framework cannot assume an auth model — so the verifier is what turns a claim into a principal. With no verifier the header is accepted on trust, which is fine for a unit test and never for production; :meth:posture is how that state stops being invisible (SCOPE-5).

Source code in src/symfonic/platform/scope.py
def __init__(
    self,
    *,
    verifier: TenantAuthVerifier | None = None,
    environment: str | None = None,
    allow_insecure_prod: bool = False,
    environ: dict[str, str] | None = None,
) -> None:
    self._verifier = verifier
    self._environment = (
        environment if environment is not None else detect_environment(environ)
    ).strip().lower()
    self._allow_insecure_prod = allow_insecure_prod
    self._untrusted_requests = 0

posture

posture() -> AuthPosture

A fresh reading every call — this is a gauge, not a one-shot log.

Source code in src/symfonic/platform/scope.py
def posture(self) -> AuthPosture:
    """A fresh reading every call — this is a gauge, not a one-shot log."""
    return AuthPosture(
        environment=self._environment,
        production=self._environment in PRODUCTION_VALUES,
        verifier_registered=self._verifier is not None,
        insecure_override=self._allow_insecure_prod,
        requests_served_untrusted=self._untrusted_requests,
    )

resolve async

resolve(credentials: RequestCredentials) -> AuthenticatedPrincipal

SCOPE-1: exactly once per request, and this is the once.

Source code in src/symfonic/platform/scope.py
async def resolve(self, credentials: RequestCredentials) -> AuthenticatedPrincipal:
    """SCOPE-1: exactly once per request, and this is the once."""
    tenant_id = (credentials.header(TENANT_HEADER) or "").strip()
    if not tenant_id:
        raise AuthenticationError(
            f"Missing required {TENANT_HEADER} header",
        )

    facts = await self._verify(credentials, tenant_id)
    scope = self._build_scope(credentials, tenant_id)
    return AuthenticatedPrincipal(
        principal_id=str(facts.get("principal_id") or tenant_id),
        scope=scope,
        is_admin=bool(facts.get("is_admin", False)),
        derivation={
            "source": "header",
            "route": credentials.route,
            "verified": self._verifier is not None,
            "environment": self._environment,
        },
    )