17. SecurityContext Design and Security Principles
Date: 2025-11-15 Updated: 2026-05-21
Status
Accepted; all production APIs documented with parameter descriptions, return values, and validation architecture. Clippy clean.
Validation Architecture
All auth types use a single validate() method returning AuthenticationError
for both structural checks (field presence, length) and business rules (user
enabled, domain enabled, trust chain). The validator crate is not used in
auth.rs — all validation is manual, ensuring every check produces a
dedicated, typed error (UserDisabled(id), DomainDisabled(id),
AuthzPrincipalMismatch, etc.) rather than an opaque ValidationErrors bag.
Validation flow:
SecurityContext::validate()validates the principal (identity, domain, user data), then checks authentication-context-specific constraints (Trust/AppCred user_id match).PrincipalInfo::validate()checksdomain_idlength, then delegates toIdentityInfo::validate().IdentityInfo::validate()dispatches:UserIdentityInfo::validate()— checks user_id length, user presence/match, user enabled, domain enabled.PrincipalIdentityInfo::validate()— checks id and issuer non-empty; IdentityInfo then checks domain enabled.
ScopeInfo::validate()— checks domain/project/trust project enabled status.SecurityContext::fully_resolved()— callsvalidate()+ checks that scoped authorization carries non-empty roles.
The validator crate remains in use by other core-types modules
(identity, assignment, token), but is not used by auth.rs.
Context
Keystone endpoints require a verified, untamperable security context to make authorization decisions. Previous Python implementations passed mutable context object that flattened the authentication and authorization information into a set of optional fields through the request lifecycle, creating a class of vulnerabilities where downstream code does not have unambiguous information.
The Rust Keystone must enforce:
- A security context cannot be used for policy enforcement before it is fully resolved
- Unscoped authentication is valid and must not be mistaken for unresolved context
- Authorization flows from authenticated parent tokens are propagated into the security context
- Test paths cannot bypass validation gates in production
Key types involved:
SecurityContext(core-types) — holds principal, authentication context, authentication methods, authorization, audit IDs, token, token restriction. All fields arepub(crate)with explicit getter/setter accessors to prevent external mutation.ValidatedSecurityContext(core) — wrapper that gates the raw context behind a validation barrier. Internal field is private; onlyDerefis implemented (noDerefMut), so the wrapped context is externally immutable.AuthenticationResult— produced by a single auth method, may carry authorization from parent tokenAuthzInfo— scope + roles extracted at authentication time.scopeispub,rolesispub(crate)with setters (set_roles,roles,try_set_roles)ScopeInfo— enum capturing the authorization scope:Domain,Project,System,TrustProject,Unscoped. TheTrustProjectvariant boxes its payload (TrustProjectInfo) to avoid inflating the enum size for the smaller variants (Domain, System, Unscoped).Credentials(policy) — subset of context projected for OPA policy evaluation. Uses read-only getters:principal(),authorization(),effective_roles()SecurityContextTestingBuilder(core-types,#[cfg]-guarded) — builder for constructingSecurityContextin test fixtures. Replaces positionalfor_testing()with named settersIntoAuthContext(core) — conversion trait that forces the caller to provide context information when converting a provider error intoAuthenticationError::Provider
Decision
Two-Phase Validation: Construction Then Validation
SecurityContext is the raw, possibly incomplete structure.
ValidatedSecurityContext wraps it and represents the validated, resolvable
security context. The two-phase design ensures that no endpoint handler can
observe a partially-authenticated context:
SecurityContext::try_from(AuthenticationResult)constructs the raw context from authentication resultsValidatedSecurityContext::new_for_scope(ctx, scope, state)is the only production path to obtain a validated context. The scope is passed as an explicit parameter (not derived from the context) so callers have unambiguous control over the target scope. It:- If
ctx.authorization()already set and differs fromscope, callsctx.validate_scope_boundaries(&scope)to guard scope override - If
ctx.authorization()isNone, callsctx.set_authorization_scope(scope) - Populates
user_domainforIdentityInfo::Userby querying the resource provider — required beforexvalidate() - Calls
ctx.validate()to check principal integrity (user enabled, appcred/ trust user_id match) - Checks token expiration: if
ctx.expires_at() < Utc::now(), returnsAuthTokenExpired - Runs auth-context-specific validation:
ApplicationCredential: verifies user_id match and AC expirationTrust: validates trust delegation chain, trustor enabled, trustor domain enabled, trustor domain compatibility
- Calls
calculate_effective_roles(state, ctx, scope)(private, read-only) to resolve role assignments from the database - Calls
ctx.set_effective_roles(roles)via setter - Returns
ValidatedSecurityContext(ctx)on success
- If
Production code can only obtain ValidatedSecurityContext through
new_for_scope(). The ValidatedSecurityContext struct wraps the context in a
private inner field. The Deref implementation provides read-only access
(&SecurityContext); there is no DerefMut, into_inner(), or inner_mut().
All 8 fields on SecurityContext are pub(crate), so external crates cannot
obtain &mut access to any field. Setter methods (set_token,
set_authorization, set_effective_roles, set_token_restriction,
expires_at_mut) are pub but require &mut self, which is unreachable once
the context is wrapped in ValidatedSecurityContext.
#[cfg]-Guarded Test Constructors and Builder
During testing, two mechanisms are available under #[cfg(any(test, feature = "mock"))]:
-
ValidatedSecurityContext::test_new(ctx)— constructs a validated context without going through the validation pipeline. This allows unit tests and integration test mocks to inject a pre-built context. -
SecurityContextTestingBuilder(accessed viaSecurityContext::test_build()) — a named-setter builder that replaces the positionalfor_testing()approach. Required fields areauthentication_contextandprincipal; optional fields aretoken,authorization,expires_at, andtoken_restriction. The builder derivesauth_methodsfromauthentication_context.methods(). Both compile-time tests (#[cfg(test)]) and the optionalmockfeature gate these constructors, so production builds can never call them.
API Extractor Enforcement
The Auth extractor (core/src/api/auth.rs) is the Axum extractor that
validates and resolves the context for every authenticated request. Two paths
exist:
-
Extension injection (tests only): When
ValidatedSecurityContextis present in request extensions, the extractor callsvsc.fully_resolved()?to verify the context is complete, then returnsAuth(vsc). This path is#[cfg(any(test, feature = "mock"))]-guarded. -
Token header flow (production): The extractor reads
X-Auth-Token, callsstate.provider.get_token_provider().authorize_by_token(), which builds the context, resolves roles viaValidatedSecurityContext::new_for_scope(), and returns the validated context. The extractor then callsvsc.fully_resolved()?, and returnsAuth(vsc).
Both paths call fully_resolved() before returning, ensuring the validation
gate cannot be bypassed.
fully_resolved() Semantics
The SecurityContext::fully_resolved() gate at core-types/src/auth.rs:581
enforces:
authorizationisNone— returnsErr(SecurityContextNotResolved): authorization has not been bound from the parent token or request scopeauthorizationisSome(AuthzInfo { scope: Unscoped, roles: None })— passes: unscoped is valid with no rolesauthorizationisSome(AuthzInfo { scope: Scoped, roles: None })— returnsErr(SecurityContextNotResolved): scoped authorization must have rolesauthorizationisSome(AuthzInfo { scope: _, roles: Some(_) })— passes: roles are populated
The critical distinction is that unscoped authorization with roles: None is
valid, while scoped authorization with roles: None indicates an incomplete
resolution.
Authorization Propagation from Authentication
Authorization lives at the AuthenticationResult level, not nested inside
TokenContext. This design allows any authentication method (token, SPIFFE,
K8s, OIDC, etc.) to produce authorization context at authentication time rather
than deferring all role computation to a later phase.
For token authentication, FernetToken::from_security_context(ctx, expires_at)
constructs the appropriate token variant from the validated context, using the
scope and role data from ctx.authorization(). The token provider’s
build_authz_info_from_fernet_token() method maps each FernetToken variant
to AuthzInfo { scope, roles } by fetching scope objects (project, domain,
project_domain) from the database.
The TryFrom<AuthenticationResult> and TryFrom<Vec<AuthenticationResult>>
both propagate the authorization into the resulting context:
- Single authentication result: the result’s authorization is set on the context
- MFA: the first result’s authorization is preferred; subsequent results fill in if the first is missing
- All MFA results must share the same principal or
AuthnPrincipalMismatchis returned
Effective Role Calculation
calculate_effective_roles() in core/src/auth.rs is a private, read-only
function that queries the assignment provider for effective role assignments
based on the scope type. It takes &SecurityContext (immutable reference) and
returns Vec<RoleRef>. The caller then sets roles via
SecurityContext::set_effective_roles(), which uses the setter path through
AuthzInfo::set_roles() (no &mut borrows escape the constructor).
Based on the scope type:
- Project scope: queries effective user+group role assignments on the project; for application credentials, takes the intersection of frozen AC roles with currently assigned user roles
- Domain scope: queries effective user+group role assignments on the domain
- System scope: queries effective role assignments on the system
- Trust scope: resolves trustor roles on the trust’s project scope; if the trust declares explicit roles, verifies the trustor still has those roles and applies implied role expansion
- Unscoped: no role query is performed; roles remain
None
After assignment queries, if roles is empty and the scope is not unscoped,
ActorHasNoRolesOnTarget is returned. Token restrictions are applied last to
potentially narrow the role set.
Scope Boundary Validation
SecurityContext::validate_scope_boundaries(scope) at core-types/src/auth.rs
validates whether the authentication context permits a requested scope type. It
does NOT verify role ownership — only whether the auth method and any token
restrictions allow the target scope. Returns ScopeNotAllowed on violation.
Key constraints:
- Application credentials cannot be scoped beyond their bound project
- Token restrictions block domain, system, trust, and unscoped scopes; project scope must match the restriction’s project ID
- Trust authentication cannot be re-scoped to a different trust
- K8s authentication is limited by its token restriction
Policy Credentials Projection
Credentials is the subset of ValidatedSecurityContext projected for OPA
policy evaluation. The TryFrom<&ValidatedSecurityContext> implementation at
core/src/policy.rs extracts user_id via sc.principal().get_user_id(),
role_ids via sc.authorization().effective_roles(), and scope-specific
identifiers (project_id, domain_id, system). The implementation uses
only read-only getters — no &mut borrows are involved.
Unscoped tokens produce role_ids: [] with no scope ID set. The OPA policy
must handle this unscoped case correctly. For scoped contexts, the implementation
returns SecurityContextNotResolved if authorization.roles is unexpectedly
None — this is a defense-in-depth check that catches the case where
fully_resolved() was not properly gated.
Consequences
Security Improvements
- No mutable context exposure:
ValidatedSecurityContext::inner()returns&SecurityContext. OnlyDerefis implemented (noDerefMut), so there is no path to obtain&mut SecurityContextfromValidatedSecurityContext.Cloneproduces an independent copy that does not share state. - Private fields on
SecurityContext: All 8 fields arepub(crate)incore-types. TheValidatedSecurityContexttype incoreis a different crate, so it also cannot mutate the fields directly — only through the getter/setter API. After wrapping, no&mutreference is reachable. - Explicit getter/setter API: All field access goes through getters
(returning
&TorOption<&T>) and setters (taking&mut self). No interior mutability (RefCell,Cell,UnsafeCell, atomics) is used anywhere in the auth context types. - Validation is mandatory:
fully_resolved()is called both in the production Auth extractor path and the test extension-injection path, ensuring no endpoint receives an unresolved context. - Scope boundary enforcement:
validate_scope_boundaries()prevents auth methods with narrower scope permissions from being broadened by request-scoped scope specifications. The scope override guard innew_for_scopecallsvalidate_scope_boundaries()when the requested scope differs from the context’s existing scope. - Test/production separation:
test_new()andSecurityContextTestingBuilderare compile-time excluded from production builds, preventing accidental bypass.
Performance Considerations
- Role computation at validation time: The
new_for_scope()path performs 1-8 database queries depending on scope type and number of effective assignments. This cost is paid once per API request. - Authorization propagation from token auth: The token provider builds
AuthzInfofromFernetTokenby fetching scope objects from the database (project, domain, project_domain). The role set is then re-queried bynew_for_scope()for effective assignments (which may differ from token-frozen roles due to interim role removal). - Revoked token expansion: The
authorize_by_tokenpath expands token role data from database before checking revocation. This is necessary since the token may be considered expired byproject_id,role_id,user_id, or any combination of those. It is therefore necessary to have a fully expanded token before checking for the revocation. - Trust validation overhead: The
new_for_scope()path for trust contexts performs additional queries: trust delegation chain validation, trustor user lookup, and trustor domain enabled check. These are necessary for security but add 2-3 queries per trust-scoped authentication.
Maintenance Surface
ValidatedSecurityContextadds one indirection layer to all handlers usingAuth. TheDerefimplementation minimizes friction, but direct field access is not possible. Instead, use the getter API:ctx.principal(),ctx.authorization(),ctx.token(),ctx.token_restriction(), etc.- Adding a new authentication method requires:
- Implementing the provider’s authentication logic
- Producing an
AuthenticationResult(withauthorizationif applicable) - Ensuring
AuthenticationContext::methods()returns the correct method names - Verifying
validate_scope_boundaries()handles the new context variant - Adding a match arm in
new_for_scope()for auth-context-specific validation (even if empty, to trigger a compile error for future context additions)
- Adding a new
ScopeInfovariant requires updatingvalidate_scope_boundaries(),fully_resolved(),calculate_effective_roles(),ScopeInfo::validate(),FernetToken::from_security_context(),build_authz_info_from_fernet_token(),Credentials::try_from, and all downstream match arms that consume scope information. - The
ScopeInfo::TrustProjectvariant boxes its payload asBox<TrustProjectInfo>to keep the enum size reasonable. Adding fields toTrustProjectInfoonly affects the trust variant, not the smaller variants (Domain,System,Unscoped).