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

  1. RBAC patterns are well-established and auditable
  2. Consistent with Authority module implementation
  3. Supports multi-tenancy requirements
  4. 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).

ClaimConstantSourceRoutingNotes
stellaops:tenantStellaOpsClaimTypes.TenantToken claim onlyPer-requestCanonical tenant id. Aliases tenant_id, tenant, tid accepted for compat.
stellaops:allowed_tenantsStellaOpsClaimTypes.AllowedTenantsToken claimAuthorizationSpace-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:projectStellaOpsClaimTypes.ProjectToken claim (header X-Stella-Project fallback)Per-requestOptional 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.
subStellaOpsClaimTypes.SubjectToken claimPer-requestSubject/actor identity. The resolver actor (StellaOpsTenantResolver.ResolveActor) resolves in order: subclient_idX-StellaOps-Actor header → HttpContext.User.Identity.Nameanonymous.
client_idStellaOpsClaimTypes.ClientIdToken claimPer-requestOAuth2/OIDC client identifier.
scope / scpStellaOpsClaimTypes.Scope / ScopeItemToken claimAuthorizationFine-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.
audStellaOpsClaimTypes.AudienceToken claimAuthorizationOAuth2 resource audience(s); set per-issuance via StellaOpsPrincipalBuilder.WithAudiences.
jtiStellaOpsClaimTypes.TokenIdToken claimPer-requestUnique token identifier (revocation / replay correlation).
sidStellaOpsClaimTypes.SessionIdToken claimPer-requestSession identifier.
amrStellaOpsClaimTypes.AuthenticationMethodToken claimPer-requestAuthentication method reference.
stellaops:service_accountStellaOpsClaimTypes.ServiceAccountToken claimPer-requestService-account identifier carried on delegated tokens.
stellaops:idpStellaOpsClaimTypes.IdentityProviderToken claimPer-requestIdentity-provider hint for downstream services.
stellaops:attr:env / :owner / :business_tierStellaOpsClaimTypes.VulnerabilityEnvironment / VulnerabilityOwner / VulnerabilityBusinessTierToken claimAuthorization (ABAC)Attribute-based filters scoping Vuln Explorer visibility (environment / owner / business tier).

Role-based access does not use a custom role claim type. ASP.NET role claims use the standard System.Security.Claims.ClaimTypes.Role (set as the role claim type on the principal in StellaOpsPrincipalBuilder). The authority.permissions RBAC catalogue is consumed only by the Console Admin role editor, not at request-time authorization (see StellaOpsScopes class 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):

  1. Canonical claim: stellaops:tenant
  2. Alias claim: tenant_id
  3. Alias claim: tenant
  4. 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_missingTryResolveTenant 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_missing detail string in StellaOpsTenantMiddleware still 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. The TenantSource enum retains CanonicalHeader / LegacyHeader members for the same historical reason; the active code path only ever sets TenantSource.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

Reversibility

To change routing patterns:

  1. Update docs/security/scopes-and-roles.md
  2. Get Authority Guild + Security Guild sign-off
  3. Update the tenant resolution path: StellaOpsTenantResolver, StellaOpsTenantMiddleware, and the claim constants in StellaOpsClaimTypes / canonical scopes in StellaOpsScopes
  4. Migration path for existing integrations

References