Authority Routing Decision
This decision record fixes how Stella Ops Authority routes identity claims — how tenant, project, subject, scope, and attribute claims on an issued token are resolved and enforced per request. It is the reference for service authors who need to know which claim is authoritative for tenancy, where headers are (and are not) honoured, and which error codes a missing claim produces. Read it alongside Scopes and Roles and the Tenancy Overview.
Decision ID: DECISION-AUTH-001 Status: DEFAULT-APPROVED Effective Date: 2025-12-06 48h Window Started: 2025-12-06T00:00:00Z
Decision
Authority claim routing uses RBAC-standard routing patterns aligned with existing docs/security/scopes-and-roles.md.
Rationale
- RBAC patterns are well-established and auditable
- Consistent with Authority module implementation
- Supports multi-tenancy requirements
- Compatible with external IdP integration (OIDC, SAML)
Routing Matrix
Claim type identifiers below are the canonical constants declared in StellaOps.Auth.Abstractions.StellaOpsClaimTypes. Tenant and project identity are sourced exclusively from envelope-bound JWT claims — raw tenant/project headers are stripped at ingress and are not honoured as a tenancy source (StellaOpsTenantResolver, audit finding A7, Sprint 20260430_007).
| Claim | Constant | Source | Routing | Notes |
|---|---|---|---|---|
stellaops:tenant | StellaOpsClaimTypes.Tenant | Token claim only | Per-request | Canonical tenant id. Aliases tenant_id, tenant, tid accepted for compat. |
stellaops:allowed_tenants | StellaOpsClaimTypes.AllowedTenants | Token claim | Authorization | Space-separated set of tenants the subject/client may act under. Enforced at issuance: the requested tenant is validated against the client’s assigned set, rejecting with "Requested tenant is not assigned to this client." (see ClientCredentialHandlerHelpers.SelectTenant). |
stellaops:project | StellaOpsClaimTypes.Project | Token claim (header X-Stella-Project fallback) | Per-request | Optional project scoping within a tenant; sentinel * = any project (StellaOpsTenancyDefaults.AnyProject). Unlike tenant, the project resolver (StellaOpsTenantResolver.ResolveProject) does honour the X-Stella-Project header when the claim is absent. |
sub | StellaOpsClaimTypes.Subject | Token claim | Per-request | Subject/actor identity. The resolver actor (StellaOpsTenantResolver.ResolveActor) resolves in order: sub → client_id → X-StellaOps-Actor header → HttpContext.User.Identity.Name → anonymous. |
client_id | StellaOpsClaimTypes.ClientId | Token claim | Per-request | OAuth2/OIDC client identifier. |
scope / scp | StellaOpsClaimTypes.Scope / ScopeItem | Token claim | Authorization | Fine-grained access; scope is the space-joined string, scp repeats per item. Issuance filters requested scopes against authority.clients.allowed_scopes. Canonical scope catalogue: StellaOpsScopes. |
aud | StellaOpsClaimTypes.Audience | Token claim | Authorization | OAuth2 resource audience(s); set per-issuance via StellaOpsPrincipalBuilder.WithAudiences. |
jti | StellaOpsClaimTypes.TokenId | Token claim | Per-request | Unique token identifier (revocation / replay correlation). |
sid | StellaOpsClaimTypes.SessionId | Token claim | Per-request | Session identifier. |
amr | StellaOpsClaimTypes.AuthenticationMethod | Token claim | Per-request | Authentication method reference. |
stellaops:service_account | StellaOpsClaimTypes.ServiceAccount | Token claim | Per-request | Service-account identifier carried on delegated tokens. |
stellaops:idp | StellaOpsClaimTypes.IdentityProvider | Token claim | Per-request | Identity-provider hint for downstream services. |
stellaops:attr:env / :owner / :business_tier | StellaOpsClaimTypes.VulnerabilityEnvironment / VulnerabilityOwner / VulnerabilityBusinessTier | Token claim | Authorization (ABAC) | Attribute-based filters scoping Vuln Explorer visibility (environment / owner / business tier). |
Role-based access does not use a custom
roleclaim type. ASP.NET role claims use the standardSystem.Security.Claims.ClaimTypes.Role(set as the role claim type on the principal inStellaOpsPrincipalBuilder). Theauthority.permissionsRBAC catalogue is consumed only by the Console Admin role editor, not at request-time authorization (seeStellaOpsScopesclass remarks).
Claim Priority
Tenant identity is resolved from claims only (no header fallback). When multiple tenant claim names are present, StellaOpsTenantResolver applies this priority order (first non-empty wins):
- Canonical claim:
stellaops:tenant - Alias claim:
tenant_id - Alias claim:
tenant - Legacy claim:
tid
If no tenant claim resolves, the request is treated as tenantless. Endpoints guarded by the .RequireTenant() endpoint filter (StellaOpsTenantEndpointFilter, registered via the RequireTenant() extensions on RouteGroupBuilder / RouteHandlerBuilder) then reject with HTTP 400 and an application/problem+json body carrying error_code. In the claim-only resolution path the only code the filter can emit is tenant_missing— TryResolveTenant returns no other failure. The middleware switch also maps tenant_conflict and tenant_invalid_format to detail strings, but those are defensive branches: tenant_conflict is never produced by the current claim-only resolver, and tenant_invalid_format is produced only by the separate async Guid resolver (TryResolveTenantGuidAsync / IStellaOpsTenantAccessor.RequireTenantGuidAsync), not by the synchronous endpoint filter. Global/system endpoints proceed without a tenant. The resolved value is normalised to trimmed lower-case (NormalizeTenant).
The
tenant_missingdetail string inStellaOpsTenantMiddlewarestill reads “…via the stellaops:tenant claim or X-StellaOps-TenantId header.” That header mention is a stale message string only — raw tenant headers are stripped at ingress and are not honoured as a tenancy source. TheTenantSourceenum retainsCanonicalHeader/LegacyHeadermembers for the same historical reason; the active code path only ever setsTenantSource.Claim.
Implementation Pattern
Tenant context is resolved by StellaOpsTenantMiddleware, which calls StellaOpsTenantResolver.TryResolve(...) and populates the request-scoped IStellaOpsTenantAccessor. Resolution reads claims only — there is no ClaimResolver/IClaimResolver/AuthorityContext type, and no X-Tenant-Id / X-Project-Id header override.
// StellaOps.Auth.ServerIntegration.Tenancy.StellaOpsTenantResolver (excerpt)
// Claim-only resolution. The canonical claim wins; aliases are accepted for
// compatibility with handlers that emit legacy claim names.
var claimTenant = NormalizeTenant(
context.User.FindFirstValue(StellaOpsClaimTypes.Tenant) // stellaops:tenant
?? context.User.FindFirstValue("tenant_id")
?? context.User.FindFirstValue("tenant")
?? context.User.FindFirstValue("tid"));
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenantId = claimTenant;
source = TenantSource.Claim; // see TenantSource enum
return true;
}
error = "tenant_missing";
return false;
Downstream handlers obtain the resolved context via the accessor or the HttpContext extension helpers (RequireTenant(), GetTenant(), TryGetTenant(out ...)) on StellaOpsTenantHttpContextExtensions.
Impact
- Tasks unblocked: ~5
- Sprint files affected: SPRINT_0303
Reversibility
To change routing patterns:
- Update
docs/security/scopes-and-roles.md - Get Authority Guild + Security Guild sign-off
- Update the tenant resolution path:
StellaOpsTenantResolver,StellaOpsTenantMiddleware, and the claim constants inStellaOpsClaimTypes/ canonical scopes inStellaOpsScopes - Migration path for existing integrations
References
- Scopes and Roles
- Authority Scopes
- Tenancy Overview
- Canonical scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs - Canonical claim types:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsClaimTypes.cs - Tenancy defaults (
AnyProjectsentinel):src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsTenancyDefaults.cs - Principal builder (role claim type, scope/audience emission):
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsPrincipalBuilder.cs - Tenant resolver:
src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantResolver.cs - Tenant middleware +
RequireTenant()endpoint filter:src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantMiddleware.cs - Tenant accessor / context / source enum:
src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/{IStellaOpsTenantAccessor,StellaOpsTenantContext}.cs allowed_tenantsissuance enforcement:src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs(ClientCredentialHandlerHelpers.SelectTenant)
