Scope hierarchy

Methods and factor configurations live at three tiers: System, Tenant, and User. The mechanism takes a paragraph to describe and a long time to get right.

Used well, the three tiers make a multi-tenant deployment feel like one configuration with two override surfaces. Used carelessly, they become a maze where nobody can answer "what method is this user actually using?" without running a query.

The model

Three tiers, and why a lower one must opt in rather than inherit.

The three tiers

AuthnScope lives in axess-core/src/authn/types.rs. It is a three-variant enum, ordered from broadest to narrowest:

pub enum AuthnScope {
    System,
    Tenant(TenantId),
    User { tenant_id: TenantId, user_id: UserId },
}

System is the platform-owned default tier. A method or factor configured at system scope is a template the platform makes available; tenants adopt it explicitly (see "Adoption, not silent inheritance" below).

Tenant(TenantId) is a per-tenant configuration. A method configured at tenant scope applies to every user in that tenant.

User { tenant_id, user_id } is a per-user configuration. A method configured at user scope applies to that one user.

The ordering is the ordering of authority. Narrower beats broader.

Adoption, not silent inheritance

System is a template tier, not a runtime broadcast tier. A factor configured at System scope does not automatically become a login option for every tenant; a tenant adopts the template explicitly at provisioning time or through an administrative reconfiguration. The FactorTemplate catalog in axess-core::authn::factor is the surface for this: platform operators curate a set of templates; tenant provisioning selects which templates this tenant will use and materialises tenant-scoped rows.

This preserves the invariant that a tenant admin never gets surprised by a factor they did not opt into. Silent inheritance would make platform-wide changes propagate to tenants who had reasons for their existing configuration; explicit adoption forces those changes to route through the tenant's own configuration surface.

At runtime, once a user has an active method that includes some factor kind, the store walks the User → Tenant → System resolution chain to find the applicable config data (see next section). The System row is legitimate as the config source for a factor the user has already activated (through their method); it is never the grant.

How it resolves

Narrowest scope wins, resolved in one query by the store rather than the caller.

How resolution works

At begin_login time and again at each verify_factor step, the service asks the factor store for the applicable config for the factor kind that is next in the user's method. Resolution walks the scope chain from narrowest to broadest, returning the first hit.

The chain helper AuthnScope::resolution_chain produces the ordered sequence of scopes to query. For a user with tenant_id = T and user_id = U, the chain is [User { T, U }, Tenant(T), System].

Application code does not walk the chain: the store does, in one query, and returns a [ResolvedFactor] carrying both the config and the scope it was resolved from:

pub struct ResolvedFactor {
    pub config: FactorConfig,
    pub resolved_from: AuthnScope,
}

// In service code, one call:
let resolved = factors
    .resolve_factor(&user_scope, kind)
    .await?
    .ok_or(AuthnError::NoFlow)?;

Backends implement resolve_factor as a single ordered SELECT (the SQLite example does this via a UNION ALL with a rank column and LIMIT 1). Latency-wise this is one round trip regardless of where the config actually lives.

The resolved_from field lets callers know which tier served the request: used by the failure-counter CAS logic to know whether to CAS against an existing user-scope row or to create a new one from a tenant/system template.

For admin and display code that wants "what did this tenant explicitly configure?", the store also exposes load_factor which returns the config at exactly the requested scope with no fallback. Never on the auth hot path: use resolve_factor there.

The same chain is used for each factor in the method. A method that chains password and TOTP resolves the password config first (which might be a user-scoped override) and then the TOTP config (which might be a tenant default). Each factor's configuration is resolved independently, which is the right shape for the common case where the user has chosen their own TOTP device but the tenant has standardised the password policy.

Storage encoding

The factor store schema has tenant_id (NOT NULL) and user_id (nullable) columns:

tenant_iduser_idScope
TenantId::SYSTEMNULLSystem
<tenant>NULLTenant(tenant)
<tenant><user>User { tenant, user }

tenant_id is never NULL for configuration scope; platform-owned rows live under the reserved TenantId::SYSTEM tenant. This keeps FK integrity uniform (tenant_id REFERENCES tenants(id)) and lets SQL callers use a single tenant_id = ? clause without the IS NULL special-cases that a two-optional encoding would need.

ScopeColumns in axess-core/src/authn/types.rs is the in-code representation of the pair; it exposes tenant_id: TenantId (always populated) and user_id: Option<UserId> (populated only for User scope).

Note this is distinct from audit-event storage, where a NULL tenant_id means "tenant not yet known" (pre-authenticated event, failed login for an unknown user), never "System scope."

What gets scoped

The hierarchy applies to three kinds of object: factor configurations, methods, and lockout policies. Each plays the same game, with the same chain-walking resolution.

Factor configurations are the per-factor stored data: the password hash for a user, the TOTP secret for a user, the FIDO2 credential public keys for a user, the LDAP bind parameters for a tenant, the system default Argon2id parameters, the system default TOTP drift window. Most user-specific factor configurations are user-scoped because they belong to a specific user (a password hash is intrinsically per-user). Policy-shaped configurations (Argon2id parameters, drift windows) are typically tenant-scoped or system-scoped.

Methods are the ordered sequences of factor steps. A tenant typically configures a single default method (password-plus-TOTP, say), and a small minority of tenants override it (a regulated tenant requires FIDO2 instead of TOTP). Individual users very rarely have a custom method; when they do, it is because policy demands a stronger factor for a flagged user. Methods live at User or Tenant scope only; the SQLite example rejects System scope on save_method and friends, because runtime authentication methods must be materialised per tenant.

Lockout policies are the rate and threshold for locking out a user after repeated failed attempts. System defaults exist. Tenants with stricter risk postures override at tenant scope. Per-user lockout policies exist but are rare; they usually mean "this user is on a watch list and gets locked out faster than the rest".

The pattern across all three is identical. Configure a sensible system default. Let tenants adopt it or override when they have a real reason. Reach for the user-scoped override only when policy demands per-individual differentiation. The more configuration you do at the narrowest scope, the more state you have to reason about during incidents.

Using it

Moving an existing deployment onto it, and the shapes to avoid.

Migration patterns

The scope hierarchy is the right tool for rolling out factor changes in a controlled way. The pattern is to introduce the change at the narrowest scope, verify it on a small population, and broaden as confidence accumulates: but broadening happens through explicit per-tenant adoption, never through silent system-wide broadcast.

A worked example. A SaaS deployment wants to require FIDO2 for all users, replacing the existing password-plus-TOTP method. The cautious roll-out has three phases.

Phase one is User-scoped pilot. The operations team configures the new method (Required(Password) then Required(Fido2)) at user scope for a small set of internal users. These users go through the new flow first, surface any UX problems, and validate that the FIDO2 ceremony works end-to-end against your relying-party configuration.

Phase two is Tenant-scoped pilot. The team configures the new method at tenant scope for a single early-adopter tenant. Their users transition next, and the pilot widens to a population that includes real customer traffic. The user-scoped overrides from phase one are removed (they no longer differ from the tenant default).

Phase three is Per-tenant rollout. With confidence from both pilot phases, the team iterates the remaining tenants, either by updating each tenant's method configuration to the new FIDO2-plus-password sequence, or (if the tenant admin is empowered) by prompting the admin to adopt the new method template. Each tenant transitions independently, with an audit event per change. There is no system-wide broadcast: if a tenant deliberately does not adopt the new method (e.g. their user base has no FIDO2 hardware), they retain the old method and the roll-out simply skips them.

The pattern works in reverse for emergency revocation. If the new method has a bug that surfaces during rollout, the team can override at tenant scope or user scope for the affected population without redeploying. The narrower scope wins; the affected users walk the old method while the bug is fixed.

How Cedar policy interacts

The scope hierarchy answers "what method does this user authenticate with?" Cedar answers "what is this user allowed to do once authenticated?" The two surfaces are distinct, and confusing them leads to authorization-as-authentication mistakes.

A common pattern is to use Cedar to require a method outcome rather than to choose one. A policy might require that factors_completed.contains("Fido2") for an action against a sensitive resource. The method itself remains the resolved one from the scope hierarchy. If the method does not include FIDO2, the user reaches the sensitive route and gets a deny; you then offer step-up to add FIDO2 (covered in Factors and methods §"Step-up authentication"), the user completes it, and the policy now passes.

The split between choice (scope hierarchy) and demand (Cedar policy) is what makes this work. The hierarchy decides what factors are available; the policy decides which of them are required for which actions. A user can have a stronger method than the policy minimum and satisfy the policy without effort; a user with a weaker method gets prompted for step-up.

Anti-patterns

The hierarchy invites a few mistakes that are worth naming explicitly.

Overusing user-scoped configuration. Every user-scoped row in the factor store is a piece of state that an operator has to maintain. If a tenant decides to change its method, the tenant-scoped row updates; the user-scoped overrides do not. After a few months of incremental changes, the user-scoped rows are out of sync with the intended policy, and nobody remembers why each row exists. The fix is to use user scope only when policy genuinely requires per-individual differentiation, and to document the reason in a separate field next to the row.

Treating System as a runtime broadcast tier. A factor configured at System scope is a template: the correct pattern is to adopt (materialise a tenant-scoped row) rather than to depend on resolution to reach it silently. Depending on system-tier fallback turns platform-wide edits into surprise tenant-level changes. Materialise on adoption; treat runtime fallback to System as a convenience, not a management model.

Conflating method scope with tenant identity. The hierarchy says nothing about which tenants exist; it says only how to resolve a configuration for a given (tenant, user) pair. Tenant provisioning, tenant suspension, and tenant deletion are covered in Multi-tenancy.

How the hierarchy scales

The hierarchy is the reason an axess deployment scales from "one company with one method" to "a SaaS with hundreds of tenants, each with its own posture, and a few high-risk users on stricter policies" without restructuring anything. The same code path (begin_login, verify_factor, Authenticated) handles the single-tenant case and the hundred-tenant case. The only difference is which scope holds the configuration.

The pattern is not unique to axess. Cedar policies, audit retention policies, and rate-limit thresholds all follow the same three-tier pattern. The vocabulary is consistent across the library so a reviewer who has internalised the resolution rule does not have to re-learn it for each subsystem.

Further reading

Multi-tenancy covers tenant provisioning, the TenantId lifecycle, cross-tenant refusal, and the three-lever lockout. Cedar policy fundamentals covers how authorisation policy reads the resolved method's factors_completed field. Identity store implementation walks through the storage adapter that resolves the scope chain against a relational schema.