Authority — Architecture
Audience: platform engineers, integrators, and security reviewers who need to understand how Stella Ops authenticates callers.
Stella Ops Authority is the on-prem OIDC/OAuth2 service that issues short-lived, sender-constrained operational tokens (OpToks) to first-party services and tools. It is the trust anchor for who is calling inside a Stella Ops installation. Entitlement is proven separately by Proof-of-Entitlement (PoE) from the cloud Licensing Service — Authority does not issue PoE.
Scope. Implementation-ready architecture for Authority: protocols (DPoP & mTLS binding), token shapes, endpoints, storage, rotation, HA, RBAC, audit, and testing. This dossier consolidates identity and tenancy requirements previously spread across the AOC, Policy, and Platform guides plus the dedicated Authority implementation plan.
Related references
- Tenant-selection ADR:
docs/architecture/decisions/ADR-002-multi-tenant-same-api-key-selection.md- Service impact ledger:
docs/technical/architecture/multi-tenant-service-impact-ledger.md- Flow sequences:
docs/technical/architecture/multi-tenant-flow-sequences.md- Rollout policy:
docs/operations/multi-tenant-rollout-and-compatibility.md- QA matrix:
docs/qa/feature-checks/multi-tenant-acceptance-matrix.md
0) Mission & boundaries
Mission. Provide fast, local, verifiable authentication for Stella Ops microservices and tools by minting very short-lived OAuth2/OIDC tokens that are sender-constrained (DPoP or mTLS-bound). Support RBAC scopes, multi-tenant claims, and deterministic validation for APIs (Scanner, Signer, Attestor, Excititor, Concelier, UI, CLI, Zastava).
Boundaries.
- Authority does not validate entitlements/licensing. That’s enforced by Signer using PoE with the cloud Licensing Service.
- Authority tokens are operational only (2–5 min TTL) and must not be embedded in long-lived artifacts or stored in SBOMs.
- Resource services can validate JWT signatures locally. Authority’s own issuance, redemption and introspection require its token store; they are not stateless checks.
Internal components (what the service actually contains).
- OIDC/OAuth2 server — OpenIddict server in degraded mode wired in
Program.cs; grant handlers underOpenIddict/Handlers/(AuthorizationCodeGrantHandlers,ClientCredentialsHandlers,PasswordGrantHandlers,RefreshTokenHandlers,IntrospectionRequestHandlers,RevocationHandlers,EndSessionRequestHandlers,DiscoveryHandlers,TokenPersistenceHandlers,TokenValidationHandlers). - Interactive login —
/authorizeand/connect/authorize(GET/POST) rendering a server-side HTML sign-in shell (AuthorizeEndpoint). - End-session (RP-Initiated Logout) —
/logoutand/connect/logout(GET/POST) viaEndSessionEndpoint, validated byValidateEndSessionRequestHandler(id_token_hint+post_logout_redirect_uriallow-list, enforced in-handler because degraded mode skips OpenIddict’s built-in client allow-list check). - Bootstrap API (
/internal/*) — first-run user/client/invite provisioning, signing-key rotation, plugin reload; API-key gated. - Console API (
/console/*and/console/admin/*) — tenant/user/role/client/token/branding administration and Console read surfaces; bearer + tenant-header gated. - Identity-provider plugins —
StandardIdentityProviderPlugin(PostgreSQL-backed) plus LDAP, OIDC, and SAML plugins, loaded viaAuthorityPluginLoaderagainst theIIdentityProviderPlugincontract (StellaOps.Authority.Plugins.Abstractions). The harness overlay adds a signedStellaOps.Authority.Plugin.Harnessprovider only for deterministic plugin probe tests; it is never a base or recommended production provider. - Signing & key management —
AuthoritySigningKeyManager/IAuthoritySigningKeySource(file, KMS, HSM, regional providers viaICryptoProviderRegistry); custom/jwkshandler. - Revocation export —
AuthorityRevocationExportService/RevocationBundleBuilderproducing signed revocation bundles. - Specialised token issuers — Vuln Explorer anti-forgery + attachment tokens (
/vuln/*), Notify ack-tokens (/notify/ack-tokens/*), Vuln permalinks (/permalinks/vuln). - Persistence —
StellaOps.Authority.Persistence(PostgreSQL, schemaauthority, RLS-enforced) and the separately-schemaedStellaOps.IssuerDirectory.Persistence(schemaissuer; see § 21).
1) Protocols & cryptography
OIDC Discovery:
/.well-known/openid-configurationOAuth2 grant types (the flows enabled by the OpenIddict registration in
Program.cs):- Client Credentials (
AllowClientCredentialsFlow) — service↔service, with mTLS or client secret / private_key_jwt - Authorization Code + PKCE (
AllowAuthorizationCodeFlow+RequireProofKeyForCodeExchange) — browser login for UI and human CLI - Password (
AllowPasswordFlow) — current local/dev bootstrap compatibility path for human CLI login; not the target long-term operator flow - Refresh Token (
AllowRefreshTokenFlow) — paired with the interactive flows above - Device Code — not currently enabled.
Program.csconfigures a device-code lifetime (SetDeviceCodeLifetime) but does not callAllowDeviceCodeFlow(), and no device/verification endpoint is registered. Treat device-code as roadmap until the flow is wired and the verification endpoint exists.
- Client Credentials (
Interactive login shell:
/authorizeand/connect/authorizerender an embedded standalone HTML template for browser sign-in. Its Stella Ops identity images are embedded in the Authority assembly. The hero uses the front-door-safe anonymous alias/connect/assets/img/stella-identity.png, which stays inside the gateway’s existing/connectAuthority route; the direct-host compatibility paths/assets/img/stella-identity.pngand/assets/img/stella-logo.pngremain available. These resources are presentational and do not alter OIDC request state, PKCE handling, credential validation, or token issuance.Current local/dev standard-plugin bootstrap (
devops/etc/authority/plugins/standard.yaml):every
bootstrapClientsentry may declare an optional singularplan; Authority stores that assignment as client metadata and projects it asstellaops:planon that client’s client-credentials tokens. Removingplanfrom the committed entry removes the stored assignment on the next bootstrap reconciliation. Unassigned clients emit no plan claimgeneric tenant anchor:
defaultno pre-seeded human admin user; the first administrator is created through the setup wizard
stella-ops-ui:authorization_code refresh_token;allowedAudiences: "stellaops api://export-center api://release-orchestrator api://issuer-directory notify"so browser-issued access tokens pass the frontdoor/gateway audience (stellaops) and the resource audiences required by Export Center, Release Orchestrator, Issuer Directory, and Notify during live console work. Its scope allow-list includes bothconcelier.jobs.read(view Concelier exporter/job state) andconcelier.jobs.trigger(run an export); Concelier remains the enforcement owner and does not let the read scope satisfy POST. Startup migration017_authority_stella_ops_ui_concelier_jobs_read_scope.sqladditively reconciles those client scopes without removing operator additions. The gateway validatesaud=stellaops; a UI client missingstellaopsmakes every browser token fail audience validation (IDX10214), leaving the gateway principal anonymous and breaking the whole console. Long-lived volumes seeded beforestellaopswas added are healed forward by startup migration007_authority_first_party_audience_convergence.sql(see §4.1)stellaops-cli: public human client withauthorization_code password refresh_token; localhost redirect URIs are PKCE-required, and the CLI currently uses this client for fresh-shell interactive username/password login;allowedAudiences: "stellaops",aoc:verifyfor the advisory chain rule, and read-onlyreplay:readfor retained operator/QA same-subject verification. Startup migration016_authority_stellaops_cli_replay_read_scope.sqlconverges existing installations without grantingreplay:writeor vulnerability mutation authority. Since AUTH-15 (2026-09-05) this client carries only the scopes the shipped CLI can exercise (84 granted of a 92-scope ceiling pinned byStellaOpsCliClientScopeCeilingTests; migrationS058_stellaops_cli_scope_ceiling.sqlintersects the persisted row with the same ceiling because the bootstrapper unions and never shrinks). Operator procedures mint through the purpose-scoped password-grant identitiesstellaops-estate-admin-operator,stellaops-onboarding-operator,stellaops-compliance-operator,stellaops-release-ops-operatorandstellaops-break-glass-operator(the per-incident vehicle:openid profileat rest,findings:recoveryappended for a window and reset after — no client carries it standing, because the password grant gates scopes by the client ceiling alone); all aretenantId: default,allowedAudiences: "stellaops". Census and identity table:operations/stellaops-cli-scope-census.mdstellaops-cli-automation: confidential automation client withclient_credentials; multi-tenant Pattern B (no singulartenantId;tenants: "default e2e-lab");allowedAudiences: "stellaops api://export-center api://integrations api://reachgraph api://release-orchestrator"(Authority projects each entry as a distinctaud),aoc:verifybaseline plusvex.adminso local automation can administer VEX provider configuration, forced ingestion runs, and ExportCenter produce/download flows that requireaud=api://export-center.stellaops-scanner-worker: confidential Scanner/SBOM worker client withclient_credentials; multi-tenant Pattern B (tenants: "default e2e-lab");allowedAudiences: "stellaops api://integrations api://reachgraph api://release-orchestrator"; scoped only to the current worker forwards:advisory:ingest advisory:read aoc:verify integration:operate integration:read release:read release:write scanner:scanstellaops-scanner-web: confidential default-tenant Scanner report client withclient_credentials; exactallowedAudiences: "stellaops api://policy-engine"; exact scopesadvisory:read aoc:verify policy:preview:invoke. The dedicated Policy-owner capability intentionally omits broadpolicy:read; migration 027/S054 removes that scope from pre-existing rows while preserving only the named audiences and tenant grant.stellaops-release-dispatch: confidential cross-tenant operator client withclient_credentials(multi-tenant Pattern B,tenants: "default e2e-lab"),allowedAudiences: "stellaops api://release-orchestrator", scoped torelease:read release:write orch:read orch:operate orch:quotafor live release dispatchstellaops-notify: confidential default-tenant service client withclient_credentials, exactallowedAudiences: "notify stellaops"(notifyfor the service andstellaopsfor the router-gateway hop), the fournotify.*scopes, and the machine-onlycatalog:replicatescope required by Notify’s default-off Authority tenant-catalog replica. Live reconciliation and proof completed 2026-08-22: the scoped token carried both audiences andGET /catalog-changes/tenantsreturned 200; the replica remains off for Notify’s separate NTF-9 gatesstellaops-advisory-ai-internal: confidential default-tenant service client withclient_credentials; existing adapter audiences and read scopes are retained, andplatform:doctor:registeris granted for AdvisoryAI’s default-off Doctor registrar. Live reconciliation and token proof completed 2026-08-22: the row converged additively, the scoped token carriedplatform:doctor:register,aud=stellaops, and the default tenant, while Doctor registration remained disabled pending the AAI-9 cutovergraph-api-doctor: confidential first-party default-tenantclient_credentialsclient for Graph’s default-off Doctor registrar; exact audiencestellaopsand sole scopeplatform:doctor:register. Its secret resolves fromGRAPH_API_DOCTOR_CLIENT_SECRET. Live reconciliation and token proof completed 2026-08-22: Authority alone recreated healthy on the same image, the stored row retained the exact tenant/audience/grant/scope, and a fresh token proved those claims while Graph registration and the Platform capability row remained off/absent. The client does not grant Graph read/write authority and does not by itself enable registrationstellaops-evidence-web: confidential first-party default-tenantclient_credentialsclient for the consolidated Evidence host; exact audiencestellaopsand exact scopescatalog:replicate platform:doctor:register. Its secret resolves fromEVIDENCE_AUTHORITY_CLIENT_SECRET. Both tracked Standard descriptors and focused tests carry the source definition; live Authority reconciliation, token/feed proof, the durable local-replica checkpoint, and default-off Doctor activation remain EVD-9 window gates. The client grants no Evidence data read/write or tenant-admin authoritystellaops-exportcenter-nis2: confidential first-party ExportCenter service client withclient_credentials; multi-tenant Pattern B (tenants: "default e2e-lab"); exactallowedAudiences: "stellaops api://timeline"and least-privilegeauthority:tenants.read sbom:read timeline:readscopes. ExportCenter requests only the selected scope plustenant; it does not request a resource parameter. Authority projects the registered audiences into the JWT, so direct Timeline calls satisfy Timeline’sapi://timelineresource-server boundary without granting Timeline write/admin or a wildcard audience. Standard-plugin startup reconciliation additively converges both the scope and audience on existing rows.stellaops-findings-security-internal: confidential first-party outbound identity shared by the consolidated Findings worker and the retained Findings.Security Policy reader, usingclient_credentials; multi-tenant Pattern B (tenants: "default e2e-lab", no singular tenant). Its exact audiences areapi://policy-engine api://sbomservice; its exact scopes areadvisory:read aoc:verify policy:read sbom:operate sbom:read. Those grants cover only the Policy exception reader, the Vulnerabilities retained-corpus reader (whose direct resource server currently has no audience allow-list and whoseadvisory:readrequires theaoc:verifycompanion), and the SbomService owner stream/checkpoint client; no owner write/admin scope is granted. Its secret is resolved only fromFINDINGS_SECURITY_AUTHORITY_CLIENT_SECRET, shared with the Findings consumers through ignored local env/Vault injection. Missing or foreign tenant and any scope expansion are rejected; tenant onboarding may union-extend the stored allow-list without broadening scope or audience.findings-ledger-dev: confidential local automation client withclient_credentials,allowedAudiences: "api://findings-ledger", andfindings:readfor fresh-journey evidence-bundle download/verification without minting multi-audience CLI tokensscanner-poe-dev: confidentialclient_credentialsharness client,allowedAudiences: "signer",allowedScopes: "signer:sign",senderConstraint: "dpop"— the canonical example of a DPoP-bound, single-audience service client
Source-of-truth note (Sprint 20260505_002).
devops/etc/authority/plugins/standard.yamlis the canonical, tracked-in-git bootstrap source for first-party clients. Theauthorityservice indevops/compose/docker-compose.stella-services.ymlbind-mounts the host directorydevops/etc/authority/read-only into the container at/app/etc/authority/, and thestellaops/authority:devimage ships nothing under that path — so this host file IS the runtime config. A previous broadplugins/rule in.gitignore(line 58) accidentally hid this file; an explicit!devops/etc/authority/plugins/**re-include keeps the file tracked while leaving other runtime-dropplugins/directories ignored. Edits made directly to this file take effect on the next Authority restart (StandardPluginBootstrapperreconcilesauthority.clientsagainst the YAML on every start). The legacydevops/etc/authority.plugins/standard.yaml(note the dot) carries only password policy and is not read by compose — it remains as a fallback example for non-compose deployments.Pluginized runtime overlays are a separate executable-bundle lane. When
docker-compose.plugins.recommended.ymlordocker-compose.plugins.harness.ymlis selected, Authority overridesAuthority:PluginDirectories:0to/app/plugins/authoritybut inherits the baseAuthority:Plugins:ConfigurationDirectory=/app/etc/authority/plugins. Standard bootstrap plus LDAP/OIDC/SAML/harness/registry YAML therefore share the single canonicaldevops/etc/authority/plugins/source while signed executable bundles remain underdevops/plugins/authority/<profile>/<plugin-id>/. The harness overlay additionally declares theharnessdescriptor through environment variables so the mounted dummy IdP/MFA provider is loaded and probed through the same contract as real providers. The LDAP provider is still an optional signed mounted bundle, but the Linux Authority runtime image must carry the native OpenLDAP client libraries used bySystem.DirectoryServices.Protocols(libldap-2.5.so.0/liblber-2.5.so.0). The Docker build helpers passEXTRA_RUNTIME_PACKAGESonly for theauthorityservice key, defaultingAUTHORITY_LDAP_NATIVE_PACKAGEtolibldap-2.5-0; offline sites must make that package available through their local image build source or override the package name for their base distro.Provider admission is catalog-bound and fail-closed.
AuthorityProviderAdmissionrejects unknown provider types, future provider types, host-owned providers that try to supply an external assembly path, runtime providers without an explicit assembly identity, and runtime descriptors whose declared assembly identity does not match the cataloged provider type.AuthorityPluginLoaderevaluates catalog admission before invoking any registrar, and preservesPluginHostsignature/version/load failures as mounted-but-rejected results. An enabled store descriptor with no registered signed runtime bundle reportsMounted:false,Discovered:false, andPluginProbeOutcome.NotMounted; a discovered bundle rejected by admission or signature validation reportsMounted:true,Discovered:true,Admitted:false, andPluginProbeOutcome.Rejected. Store configuration can select and configure an admitted, same-name/type mounted descriptor, but it cannot hot-add a registrar after the process root service provider has been built.The process-global store is composed by
DatabaseIdentityProviderDescriptorSourceafter Authority startup migrations and before plugin option validation. Operator-configurable provider types are exactlyldap,oidc, andsaml;adis not an Authority type, whilestandard, test-harness, and future MFA types remain host/catalog owned. Merge identity is the case-insensitive provider name. The admitted disk descriptor retains its assembly path, capabilities, metadata, and registrar identity; a same-name, same-type database row owns only enabled state and configuration. The built-instandarddescriptor cannot be overridden. Authority validates the complete database snapshot before publishing any replacement and rejects unknown/non-configurable types, malformed non-object JSON, duplicate names, or a same-name type mutation with typed descriptor-validation errors.Configuration objects are stable across reloads and signal change tokens when a valid database snapshot replaces their values, so named plugin options can observe configuration updates without rebuilding the root service provider. An enabled store-only row is included in the reload context count but is not an active identity provider: an operator must mount and admit the matching signed same-name/type descriptor and restart Authority before its registrar can join DI. The registry therefore exposes only enabled providers backed by admitted, mounted runtime contexts;
POST /internal/plugins/reloadreports both the store-inclusive context count and the active mounted-provider count. The operator procedure is Authority identity-provider bundle operations.The operator registry at
devops/etc/authority/plugins/registry.yamltherefore keeps TOTP/WebAuthn/Email/SMS MFA entries disabled until their provider boundaries are implemented. Today, MFA assurance is supplied only when a loaded identity-provider plugin reports themfacapability in its descriptor.
1.1 Seeded bootstrap tenants (migration-owned)
The Authority persistence assembly seeds two rows into authority.tenants as part of its startup migration chain. Both are non-user-modifiable system prerequisites for the setup wizard:
tenant_id | Purpose | Migration |
|---|---|---|
default | Canonical bootstrap tenant. StandardPluginBootstrapper.DefaultTenantId creates the first admin user under this id. | src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/001_v1_authority_baseline.sql (INSERT INTO authority.tenants, lines 726-729) |
installation | Scope key used by platform.setup_sessions (PlatformSetupService.InstallationScopeKey). Not currently FK-referenced by Authority, but seeded defensively so cross-service writes that join on authority.tenants.tenant_id remain FK-safe. | same migration |
Migration-filename note (2026-07-12). The pre-1.0 chain (including the former
003_seed_default_tenants.sql) was collapsed into001_v1_authority_baseline.sql. The originals live underMigrations/_archived/pre_1.0/and are excluded from embedding by the.csproj(Exclude="Migrations\_archived\**\*.sql"), so they are not applied at runtime. Cite the baseline, not the archived filenames.
Key guarantees:
- Migration-owned. The seed is an embedded resource in the persistence assembly; it is applied by
StellaOps.Infrastructure.Postgres.Migrations.AddStartupMigrations(wired inRegisterAuthorityServices). Fresh-volume bring-up converges without any external init script DDL per AGENTS.md §2.7. The compose scriptdevops/compose/postgres-init/04-authority-schema.sqlretains a guarded tenant-seed block only as a belt-and-suspenders no-op fallback for already-provisioned volumes. - Idempotent.
INSERT ... ON CONFLICT (tenant_id) DO NOTHING. Replaying the migration is a zero-row-change no-op; operator customizations (e.g.display_name,settings) are preserved. - Prerequisite for the setup wizard. The admin-creation step (
POST /api/v1/setup/sessions/{id}/steps/admin/execute) calls AuthorityPOST /internal/userswhich provisions the user under thedefaulttenant. Without thedefaultrow present, the insert fails with FKusers_tenant_id_fkey.
Lineage: this seed was restored by SPRINT_20260422_005_Authority_default_tenant_bootstrap after SPRINT_20260422_003_Authority_auto_migration_compliance (archived) trimmed the compose init scripts to pure seed-fallbacks, inadvertently removing the only surviving default tenant insert from the fresh-volume boot path.
1.1a Startup migration tenant scopes
Authority’s migration SQL executes under its constrained database role with FORCE RLS enabled. The Authority-owned execution policy supplies transaction-local tenant context for reviewed DML; it never changes role privileges, disables RLS, edits applied SQL or commits a migration itself. Operational repairs and seed files that explicitly target the default tenant run once under that scope. Schema, global-client and catalog scripts run once without an invented tenant context.
The estate-wide permission retirements S049 (doctor-scheduler) and S056 (vuln:read) execute their unchanged SQL for every tenant actually present in authority.tenants, including inactive tenants. All scopes share the existing file transaction. A failure rolls back every scope and leaves no completion ledger entry. An empty tenant catalog fails explicitly for these tenant-only retirements; it does not synthesize a tenant or silently complete them. The fresh baseline establishes the operational default tenant before these files run.
The shared startup runner still owns advisory locking, checksum checks, transaction completion and one migration-ledger entry per file. Both app.tenant_id and app.current_tenant are transaction-local and restore their previous values after commit or rollback.
Source: src/Authority/__Libraries/StellaOps.Authority.Persistence/Postgres/AuthorityMigrationSqlExecutor.cs.
Re-verify with:
pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/__Tests/StellaOps.Authority.Persistence.Tests/StellaOps.Authority.Persistence.Tests.csproj -Class "*AuthorityMigrationTenantScopeTests" -BuildProjectReferences
1.2 Platform-side tenant propagation
Authority owns tenant lifecycle writes. Platform-side services resolve tenant slugs through shared.tenants in the platform database, and Platform writes that table itself — Authority holds no connection to it.
Until 2026-09-08 Authority wrote shared.tenants directly, over a connection derived from its own credentials. That is the cross-service database write ADR-039 forbids, and it survived only because Authority ran as the generic superuser. Once Authority moved to its own authority_admin role, which has REVOKE CONNECT on every sibling database, the write became a guaranteed 42501 and every tenant mutation answered 200 with a partial-success Warning header (SPRINT_20260722_016 AUTH-25).
Console-admin tenant writes now have exactly one half:
sequenceDiagram
participant Operator
participant Authority as Authority console-admin API
participant AuthDb as authority.tenants + outbox
participant Feed as GET /catalog-changes/tenants
participant Platform as platform-web projection
participant PlatformDb as shared.tenants
Operator->>Authority: create/update/suspend/resume tenant
Authority->>AuthDb: persist row AND append the tenants catalog change (one transaction)
Authority-->>Operator: result (no Warning header, no propagation audit)
Platform->>Feed: drain (catalog:replicate, platform-web's own identity)
Platform->>PlatformDb: project into shared.tenants (Platform's own connection)
The projection never sets is_default; that column, along with default_region, settings, metadata and created_at, belongs to Platform and the producer’s payload cannot express it. An existing row keeps its own id as well, because four Platform tables’ foreign keys already point at it.
Convergence is eventual, bounded by the drain’s poll interval, with one bootstrap drain before platform-web finishes starting. There is no partial-success Warning header, no *.propagation_failed audit event and no reconcile-tenants script any more: a tenant mutation either commits both its row and its catalog event or commits neither.
Authority may not delete from shared.tenants: the platform FK graph mixes ON DELETE CASCADE release tables with RESTRICT rows such as platform.tenant_settings and platform.tenant_compliance_profile. Tenant deactivation is a status flip (active/suspended). Hard tenant delete remains tracked as GAP-T7-TENANT-DELETE / PRODUCT-GAP-T7-D1.
AUTH-9 reversible tenant forcing contract (owner ruling 2026-08-23). The issuer-fold window does not add or simulate a tenant-delete endpoint. Its tenant forcing function uses PATCH /console/admin/tenants/{tenantId} on one owner-designated existing disposable tenant and changes only its display name. Before mutation the operator records the exact API/source value and authority.tenants.row_version. The admin PATCH updates the tenant’s canonical name while preserving the independent legacy/catalog display_name value, so the procedure records and verifies both and permits an already-divergent pre-state without normalizing it. The temporary update must advance the source version exactly once, produce its same-transaction tenants outbox row, emit the canonical update_tenant audit event and land it in timeline.unified_audit_events as action = 'update' with resource_type = 'console', and reach Policy’s named catalog.replica.tenants row plus durable eventing.consumer_checkpoints row in stellaops_policy. The two action names are the same contract at two boundaries: the endpoint declares update_tenant and the emitter stamps it verbatim, while Timeline’s ingest normalizes an action to the first catalog entry it CONTAINS, so the persisted value is update and the raw name is retained in tags. Query the store by action = 'update' AND resource_type = 'console', or by tags @> '{update_tenant}'; a query for action = 'update_tenant' matches nothing, by design (AUTH-28). The same API then restores the exact prior display name and every proof is repeated at the next version before soak. SQL tenant mutation, create/delete, suspend/resume, default, an unapproved consumer, and a permanent canary are refused. The two audit/outbox records and monotonic versions remain as window evidence; no temporary business value remains. Procedure: docs/runbooks/authority/authority-deploy-auth8-staged-fold.md §9.
The non-live preflight is TenantPropagationEndToEndTests.ReversibleDisplayNameUpdate_RestoresExactValue_WithVersionedAuditAndReplicaProof. It uses the real endpoint, repository and eventing migrations plus the standard catalog consumer across separate scratch Authority/Policy databases. It proves the two source/outbox versions, preservation of a deliberately different display_name, correlated canonical audit payloads, durable catalog.replica.tenants checkpoint and exact restoration of both values. It deliberately does not stand in for the live Policy drain or Timeline store; those remain window acceptance evidence.
Active-context suspension guard. POST /console/admin/tenants/{tenantId}/suspend normalizes the target slug and compares it to the tenant already validated into the request context by TenantHeaderFilter. A matching target is rejected before repository lookup, tenant update, shared-tenant propagation, or success audit with sealed HTTP 409 application/problem+json, stable code active_tenant_suspend_forbidden, the normalized tenantId, and guidance to switch tenant context first. Suspension of a distinct existing tenant retains the normal persistence, propagation, and audit flow. The guard is based on the validated request context rather than a hard-coded default slug, raw header, or bearer claim.
Password-grant tenant binding (Sprint 20260518_062 / FixWave-A). For the password grant the issued token’s stellaops:tenant claim is bound to the authenticated user’s home tenant (authority.users.tenant_id, surfaced by the credential store on the user descriptor attributes), not the client’s default tenant. A request may scope the token via the tenant parameter, but only to a tenant the user belongs to (i.e. equal to the user’s home tenant); a mismatch is rejected with invalid_request (“Requested tenant is not assigned to this user.”). Token issuance is refused for a suspended tenant: after the effective tenant is resolved the handler checks authority.tenants.status (the owner’s own column; shared.tenants.status is Platform’s projection of it) and rejects with invalid_grant (“Tenant is suspended.”) when the tenant is not active. Client-credentials grants (no user) continue to use the client-configured tenant. The authorization-code path now applies the verified-user binding described in §3.2a; a client default is not evidence of a user’s tenant membership.
Sender constraint options (choose per caller or per audience):
- DPoP (Demonstration of Proof-of-Possession): proof JWT on each HTTP request, bound to the access token via
cnf.jkt. - OAuth 2.0 mTLS (certificate-bound tokens): token bound to client certificate thumbprint via
cnf.x5t#S256.
- DPoP (Demonstration of Proof-of-Possession): proof JWT on each HTTP request, bound to the access token via
Signing algorithms: EdDSA (Ed25519) preferred; fallback ES256 (P-256). Rotation is supported via kid in JWKS.
Token format: JWT access tokens (compact), optionally opaque reference tokens for services that insist on introspection.
Clock skew tolerance: ±60 s; issue
nbf,iat,expaccordingly.
2) Token model
Incident mode tokens require the
obs:incidentscope, a human-suppliedincident_reason, and remain valid only whileauth_timestays within a five-minute freshness window. Resource servers enforce the same window and persistincident.reason,incident.auth_time, and the fresh-auth verdict inauthority.resource.authorizeevents. Authority exposes/authority/audit/incidentso auditors can review recent activations.Air-gap audit surface. Alongside the incident group, Authority maps an air-gap audit endpoint group at
/authority/audit/airgap(Airgap/AirgapAuditEndpointExtensions.cs:MapGet("/")reads recent records,MapPost("/")records an entry; backed byAuthorityAirgapAuditService). Sealed-mode evidence submissions are validated byAuthoritySealedModeEvidenceValidator(Airgap/AuthoritySealedModeEvidenceValidator.cs) before they are accepted. This is the air-gap counterpart to the incident-audit group and is how sealed/air-gapped installs record and review authority-side audit evidence.Local-policy source is not a break-glass login. Authority retains file-based local-policy models and parsing under
LocalPolicy/, but no operator-usable fallback-authentication or break-glass session path is registered. The complete but unregisteredBreakGlassSessionManagerdesign was removed in Sprint20260731_003; there is no break-glass endpoint, CLI command, Console surface, or session audit stream. Treat any local policy file and mount as sensitive configuration, but do not rely on it for incident login.Authentication assurance (
acr/amr) and step-up (ADR-025 / AUTH-OPSIGN-004). The password and authorization-code flows issue anacr(authentication context class) and one or moreamr(authentication method references) alongsideauth_time, reflecting whether a step-up actually occurred. The base single-factor login issuesacr = urn:stellaops:acr:pwdandamr = [pwd]; when an identity-provider plugin reports a satisfied second factor (via the non-custodialstellaops:amrdescriptor attribute — a space-separated RFC 8176 method set such asmfa otp), the flow issuesacr = urn:stellaops:acr:mfawithamr = [pwd, …, mfa]. The twoacrvalues form an ordered assurance ladder (pwd < mfa, roughly NIST AAL1 < AAL2; the canonical constants live inStellaOps.Auth.Abstractions/StellaOpsAuthenticationAssurance.cs). The OIDCmax_agerequest parameter is honoured: a malformed value (non-numeric / negative) is rejected (invalid_request); because these flows always mint a freshauth_time, a well-formedmax_ageis satisfied by construction and the re-authentication forcing-function is the server-sideauth_timefreshness gate. The reusable freshness gate (FreshAuthenticationGate) can additionally require a minimumacrfor the operator decision-signing / signing-key-enrollment scopes viaStellaOpsResourceServerOptions.DecisionSigningMinimumAcr(default unset ⇒auth_time-only, back-compatible); when set, a fresh token whoseacris weaker than — or absent against — the configured minimum is rejected. The gate fails closed: an unknown/absent presentedacrnever satisfies a known minimum, while a blank/unrecognized configured minimum imposes no requirement.Any-of scope and relayed-identity semantics.
RequireAnyStellaOpsScopesis one OR requirement, not several AND requirements. Step-up or protected-scope metadata is evaluated only when the selected matching branch is protected; an internal provider-change reconciler that matchescrypto:adminmust not acquire theauthority:signing-keys.adminfresh-auth obligation merely because both scopes appear in the policy. Platform’s provider-change calls relay the already verified actor through the gateway-signed identity envelope; retry workers use a narrowly scoped reconciler identity. Authority and IssuerDirectory validate the tenant and scope independently on every direct call.Console admin mutation freshness. Tenant-scoped
/console/admin/*mutations and their/api/adminlegacy aliases opt in throughRequireFreshAuth(). Authority accepts anauth_timeno more than five minutes old and allows at most one minute of future clock skew; missing, invalid, stale, or excessively future claims return HTTP 401 withfresh_auth_required. The filter is attached only to mutation routes, not the route group, so read-only administration remains possible before the operator reauthenticates.
2.1 Access token (OpTok) — short-lived (120–300 s)
Registered claims
iss = https://authority.<domain>
sub = <client_id or user_id>
aud = <one or more declared audiences, e.g. "signer", "stellaops", "api://export-center">
exp = <unix ts> (<= 300 s from iat)
iat = <unix ts>
nbf = iat - 30
jti = <uuid>
scope = "scanner:scan scanner:export signer:sign ..."
Audiences are projected from the client’s declared
allowedAudiences(each entry becomes a distinctaudvalue), not a fixedsigner|scanner|...enum. Real first-party clients declare audiences such asstellaops,signer,notify,api://export-center,api://release-orchestrator,api://findings-ledger,api://integrations, andapi://reachgraph(seedevops/etc/authority/plugins/standard.yaml).
Sender-constraint (cnf)
DPoP:
"cnf": { "jkt": "<base64url(SHA-256(JWK))>" }mTLS:
"cnf": { "x5t#S256": "<base64url(SHA-256(client_cert_der))>" }
Tenant/identity context (custom claims)
The canonical claim names are defined on StellaOpsClaimTypes (src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsClaimTypes.cs) and set by the grant handlers (PasswordGrantHandlers, ClientCredentialsHandlers). Note these are namespaced stellaops:* claim names, not the short tid/inst shapes used in earlier drafts:
stellaops:tenant = <tenant id> // multi-tenant binding (was wrongly "tid")
stellaops:allowed_tenants = "<tenant> <tenant> ..." // space-separated allow-list for the subject/client
stellaops:project = <project id> // optional project scoping within a tenant ("(any)" default)
stellaops:plan = <resource plan name> // optional per-client assignment (client credentials only)
stellaops:idp = <identity provider name> // originating plugin (standard|ldap|oidc|saml)
stellaops:service_account = <service account id> // delegated client-credentials tokens
sid = <session id> // interactive sessions
There is no inst/installation claim and no roles claim in the issued token; role/scope resolution is collapsed into the scope claim at issuance. stellaops:plan is narrower: the client-credentials handler emits it only when the authenticated client has an explicit plan property. It is omitted for unassigned clients; Authority never fabricates a resource-server default. Additional operator-justification claims are attached for elevated grants (e.g. stellaops:operator_reason, stellaops:quota_reason, stellaops:backfill_reason, stellaops:policy_reason/stellaops:policy_ticket, stellaops:incident_reason, and the Vuln Explorer ABAC selectors stellaops:attr:env / stellaops:attr:owner / stellaops:attr:business_tier).
Note: Do not copy PoE claims into OpTok; OpTok ≠ entitlement. Only Signer checks PoE.
2.2 Refresh tokens (optional)
- The host enables
AllowRefreshTokenFlow(); refresh is not globally disabled by default. Each client still needs the registeredrefresh_tokengrant and the permitted scopes.Program.csselectsStellaOpsAuthorityOptions.RefreshTokenLifetimeand enables rolling refresh tokens; the option’s source default is 30 days (src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.Core.cs). This is a configuration default, not a statement of the deployed lifetime. - AUTH-16 makes encrypted refresh tokens addressable in the existing token store and one-use at redemption (§3.2a–3.3). It does not change lifetime, audience, grant configuration, or the existing sender-constraint policy. The source checkpoint is verified below; live acceptance remains a separate gate.
2.3 ID tokens (optional)
- Issued for UI/browser OIDC flows (Authorization Code + PKCE); not used for service auth.
3) Endpoints & flows
3.1 OIDC discovery & keys
Authority validates bearer requests against its validated public issuer. The shared metadata manager derives the discovery URL from that issuer and follows its advertised JWKS URL. HTTPS issuers require HTTPS metadata with normal certificate validation; there is no forced plaintext loopback discovery address. An explicitly configured local HTTP issuer remains usable in the existing development harness. Production transport validation and the issuer advertised in tokens/discovery are unchanged.
GET /.well-known/openid-configuration→ endpoints, algs, jwks_uriGET /jwks→ JSON Web Key Set (rotating, at least 2 active keys during transition). Served by a custom handler (AuthorityJwksService, registered atProgram.cs:297, JWKS request handled atProgram.cs:1885, endpoint mappedapp.MapGet("/jwks", …)atProgram.cs:3366) rather than OpenIddict’s built-in handler, because the built-in handler cannot export EC parameters fromAuthorityCryptoSecurityKey. The path stays in discovery viaSetJsonWebKeySetEndpointUris("/jwks").
Conventional aliases (
/connect/*). Authority exposes/connect/authorizedirectly through the same custom interactive login handler as/authorize,/connect/logoutthrough the same end-session handler as/logout, and OpenIddict also serves/connect/token,/connect/introspect, and/connect/revoke. These are the externally-routed OIDC URIs in the compose stack; the un-prefixed paths remain supported for local/internal clients.
3.1a End-session (RP-Initiated Logout)
GET|POST /connect/logout(alias/logout) → terminates the Authority session and, when a registeredpost_logout_redirect_uriis supplied, redirects the user agent back to the client withstateechoed. Advertised asend_session_endpointin discovery viaSetEndSessionEndpointUris(OIDC RP-Initiated Logout 1.0).Validation (
ValidateEndSessionRequestHandler, registered forValidateEndSessionRequestContext): OpenIddict validates theid_token_hintcryptographically (exposed asIdentityTokenHintPrincipal). Because the server runs in degraded mode, OpenIddict’s built-inValidateClientPostLogoutRedirectUriis skipped, so this handler enforces the allow-list itself — resolving the client from theclient_idparameter or the hint’sazp/aud, then requiring the requestedpost_logout_redirect_urito exactly match (OIDC Simple String Comparison, case-sensitive) one of the client’s registeredpostLogoutRedirectUris. Unregistered, unknown-client, or disabled-client requests are rejected withinvalid_request.Sign-out (
EndSessionEndpoint.MapEndSessionEndpoint, end-session passthrough): signs out of every sign-out-capable authentication scheme (cookie/session schemes first → cookie deletion; the OpenIddict server scheme last → emits the OIDC end-session response). The interactive login path is currently stateless (no ASP.NET SSO cookie is registered —AuthorizeEndpointre-validates credentials per request), so additional scheme sign-out is a no-op today but keeps logout correct if an interactive cookie session is later introduced.The
stella-ops-uiclient’s allow-list is seeded indevops/etc/authority/plugins/standard.yamlaspostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/"(absolute URIs are required by the client-bootstrap parser). The Web Console’s background end-session call omitspost_logout_redirect_uriand renders its own/logged-outpage, so it does not depend on the redirect behavior.KMS-named key source (verified 2026-07-18). Authority currently composes
signing.keySource=kmswith the localAddFileKmsbackend and callsIKmsClient.ExportAsync. Exportable EC material, including the private scalarD, is imported intoCryptoSigningKey; the lone focused source test stubs export and does not perform a KMS-backed signing call. A non-exporting provider path retains public coordinates plus a version-derived handle, but no production test proves remote signing through that handle. Do not claim that private scalars never leave the provider until Authority composes and verifies a non-exportingIKmsClient/signer path.
3.2 Token issuance
POST /token
Legacy aliases under
/oauth/tokenare deprecated as of 1 November 2025 and now emitDeprecation/Sunset/Warningheaders. Seedocs/api/authority-legacy-auth-endpoints.mdfor timelines and migration guidance.
Client Credentials (service→service):
- mTLS: mutual TLS +
client_id→ bound token (cnf.x5t#S256)security.senderConstraints.mtls.enforceForAudiencesforces the mTLS path when requestedaud/resourcevalues intersect high-value audiences (defaults includesigner). Authority rejects clients attempting to use DPoP/basic secrets for these audiences.- Stored
certificateBindingsare authoritative: thumbprint, subject, issuer, serial number, and SAN values are matched against the presented certificate, with rotation grace applied to activation windows. Failures surface deterministic error codes (e.g.certificate_binding_subject_mismatch).
- private_key_jwt / client secret: JWT- or secret-based client auth + DPoP header (preferred for tools and CLI)
- mTLS: mutual TLS +
Authorization Code + PKCE (UI/browser and human CLI): standard; PKCE is required (
RequireProofKeyForCodeExchange)Password (current local/dev human CLI bootstrap):
POST /tokenusing the seededstellaops-clipublic clientDevice Code: not wired (see § 1); no
/device/verification endpoint exists today
DPoP handshake (example)
Client prepares JWK (ephemeral keypair).
Client sends DPoP proof header with fields:
htm=POST htu=https://authority.../token iat=<now> jti=<uuid>signed with the DPoP private key; header carries JWK.
Authority validates proof; issues access token with
cnf.jkt=<thumbprint(JWK)>.Client uses the same DPoP key to sign every subsequent API request to services (Signer, Scanner, …).
mTLS flow
- Mutual TLS at the connection; Authority extracts client cert, validates chain; token carries
cnf.x5t#S256.
3.2a Protected interactive grants (AUTH-16)
Implementation checkpoint —
04a7f8f228c6c13d1886c7f63721458cd6477dad. The source and local host/storage/conformance checks are verified at this clean checkpoint. The AUTH-16 sprint records the test results and candidate image. The recorded local deployment passed 16 live code/refresh/revocation controls. At configuration checkpoint1e5f3b3053d0682d1b3a3565d4fcfa182f67e3d9, ordinary Console browser login and all 16 exact-120-scope protocol controls also passed. See the login receipt. This proves login, not every Console page or other service-family acceptance gate.
InteractiveTokenHandlers.cs adds PrepareEncryptedTokenIdentityHandler on OpenIddict’s GenerateTokenContext, after principal preparation and immediately before Protection.GenerateIdentityModelToken. It assigns the authorization-code or refresh-token jti before JWE protection. PersistTokensHandler still runs after generation, but persists that prepared identity and the principal’s protected expiration; missing protected identity or expiry rejects issuance. Access tokens remain unencrypted JWS and retain their existing serialized-jti readback path. The shared preparation hook can handle device-token identities, but device flow and its endpoints remain disabled; this is not device-flow acceptance.
Because the host uses OpenIddict degraded mode, Authority supplies the registered client checks in ValidateInteractiveTokenClientHandler and AuthorizeEndpoint:
- The client must exist, be enabled, permit the exact code/refresh grant, and allow the requested scopes. Authorization also requires the registered redirect URI.
- Confidential clients must authenticate with their configured secret, including the existing rotation-grace rule. Public clients have no secret authentication: authorization-code redemption requires the protected presenter, redirect and PKCE bindings; refresh redemption remains bound to its protected presenter.
- Requested refresh scopes cannot exceed the protected grant. Before consuming either grant, Authority rechecks the current client grant/scope allow-lists; omitting
scopecannot retain permissions removed since issuance. - Interactive tenant selection starts from the authenticated user’s verified home tenant or an explicitly requested tenant, never the client’s default tenant. A non-global administrator cannot select a different home tenant. The existing verified global-administrator exception and configured client tenant restrictions still apply, and a suspended tenant is rejected. AUTH-16 installs no additional client grants, audiences, or tenant memberships.
The general Console session must not request policy:publish or policy:promote. These are operation-bound attestation scopes: PasswordGrantHandlers requires a policy digest, reason and ticket and emits the operation claim; ValidateAccessTokenHandler validates those claims and five-minute freshness. They are not general Policy authoring permissions. The ordinary author/edit/approve/ operate/activate scopes stay available, and dedicated attestation issuance retains its existing controls. Do not bypass those controls for code or refresh tokens to make a broad login scope list work. obs:incident is also excluded from the general session because it has a short fresh-auth window and disallows refresh.
Check the canonical local Console request independently of a deployment: node tools/scripts/deploy/replay-platform-console-scope.cjs --check-scope. Then verify the actually served /platform/envsettings.json and ordinary browser login; source configuration alone cannot disprove a persisted runtime override.
After those validations, RedeemInteractiveTokenHandler calls IAuthorityTokenStore.TryRedeemAsync before issuing replacements. The PostgreSQL implementation performs one conditional update of authority.oidc_tokens, matching token ID, client ID and token type, requiring status=valid, no redeemed_at, and an unexpired expires_at. It sets the redeemed timestamp/status without replacing unrelated metadata. Exactly one concurrent caller can succeed. Missing, expired, revoked, consumed or mismatched records fail closed; invalid request bindings are rejected before consumption. This uses the existing row/store, not a new migration, parallel token store or compatibility path.
Re-run the source checks from a committed checkout (the persistence suite uses its disposable PostgreSQL fixture, not the running estate database):
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*EncryptedTokenRedemptionTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*AuthorityTokenStorageFailure*Tests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/__Tests/StellaOps.Authority.Persistence.Tests/StellaOps.Authority.Persistence.Tests.csproj -Class "*TokenRedemptionTests" -BuildProjectReferences
Named falsifiers include AuthorizationCode_RealProtectedArtifact_RedeemsAndIssuesEncryptedRefresh, ProtectedGrant_ConcurrentRedemption_HasExactlyOneWinner, Refresh_RejectsScopeEscalationAndClientChanges_WithoutConsumption, Authorize_UserCannotAcquireClientsDifferentTenant, and TryRedeem_ConcurrentDatabaseCalls_ExactlyOneSucceeds_AndMetadataIsPreserved.
3.3 Introspection & revocation (optional)
POST /introspect→{ active, sub, scope, aud, exp, cnf, ... }POST /revoke→ revokes validated access or refresh tokens belonging to the requesting registered client.
The invariant everything here rests on: the authority.oidc_tokens row is keyed by the jti the emitted token carries. If those identities differ, the emitted token cannot resolve its row through ValidateAccessTokenHandler.FindByTokenIdAsync. The host rejects it; it does not bypass status, expiry or replay checks when lookup fails. For unencrypted access tokens, PersistTokensHandler runs after generation (explicit SetOrder in Program.cs) and reads the identifier from the emitted JWS. For encrypted code/refresh tokens, AUTH-16 prepares the identifier before protection and persists the same principal identity afterward (§3.2a). ValidateAccessTokenHandler then fails closed: a token whose type is one Authority persists (access, refresh, authorization code, device code) and whose row is absent is rejected rather than validated. Verified by TokenStoreRowAddressabilityTests and the POST /introspect cases in AuthorityOpenApiConformanceTests (AUTH-13, 2026-08-28).
AUTH-16 revocation uses only OpenIddict’s cryptographically validated principal. ValidateRevocationRequestHandler validates the registered client and its applicable secret authentication; HandleRevocationRequestHandler matches the protected presenter/client and the stored client/token type before changing status. A raw row identifier or unsigned JWT payload cannot select a record for mutation. An invalid or unknown token can receive an empty success-shaped response to avoid disclosure; that response is not evidence that a record was changed. Re-verify with Revocation_InvalidIdentityCannotMutateAnotherClientsToken and Revocation_AuthenticatedOwnerRevokesEncryptedRefresh. These are source-verified at the AUTH-16 checkpoint above, not a replacement for its live acceptance receipt.
Requests targeting the legacy
/oauth/{introspect|revoke}paths receive deprecation headers and are scheduled for removal after 1 May 2026.
- Replay prevention: maintain DPoP
jticache (TTL ≤ 10 min) to reject duplicate proofs when services supply DPoP nonces (Signer requires nonce for high-value operations). In non-testing Authority runtime, this replay/nonce state must be durable and shared (Valkey-backed); in-memory state is reserved forTestingonly.
3.4 UserInfo (roadmap — not implemented)
- A dedicated OIDC
GET /userinfoendpoint is not currently mapped. UI profile context is served instead by the Console surfaceGET /console/profile(bearer + tenant-header gated). Treat/userinfoas roadmap.
3.5 Vuln Explorer workflow safeguards
Anti-forgery flow — Vuln Explorer’s mutation verbs call
POST /vuln/workflow/anti-forgery/issuePOST /vuln/workflow/anti-forgery/verify
Callers must hold
vuln:operatescopes. Issued tokens embed the actor, tenant, whitelisted actions, ABAC selectors (environment/owner/business tier), and optional context key/value pairs. Tokens are EdDSA/ES256 signed via the primary Authority signing key and default to a 10-minute TTL (cap: 30 minutes). Verification enforces nonce reuse prevention, tenant match, and action membership before forwarding the request to Vuln Explorer.Attachment access — Evidence bundles and attachments reference a ledger hash. Vuln Explorer obtains a scoped download token through:
POST /vuln/attachments/tokens/issuePOST /vuln/attachments/tokens/verify
These tokens bind the ledger event hash, attachment identifier, optional finding/content metadata, and the actor. They default to a 30-minute TTL (cap: 4 hours) and require
vuln:investigate.Audit trail — Both flows emit
vuln.workflow.csrf.*andvuln.attachment.token.*audit records with tenant, actor, ledger hash, nonce, and filtered context metadata so Offline Kit operators can reconcile actions against ledger entries.Configuration
authority: vulnerabilityExplorer: workflow: antiForgery: enabled: true audience: "stellaops:vuln-workflow" defaultLifetime: "00:10:00" maxLifetime: "00:30:00" maxContextEntries: 16 maxContextValueLength: 256 attachments: enabled: true defaultLifetime: "00:30:00" maxLifetime: "04:00:00" payloadType: "application/vnd.stellaops.vuln-attachment-token+json" maxMetadataEntries: 16 maxMetadataValueLength: 512Air-gapped bundles include the signing key material and policy snapshots required to validate these tokens offline.
4) Audiences, scopes & RBAC
4.1 Audiences
Audiences are per-client metadata (AuthorityClientDescriptor.AllowedAudiences, declared in devops/etc/authority/plugins/standard.yaml and the clients table); each declared value is projected into the issued token as a distinct aud. There is no fixed audience enum in code.
signer— only the Signer service should accept tokens withaud=signer.- Other common values include the catch-all gateway audience
stellaops, plus resource audiences such asnotify,api://export-center,api://release-orchestrator,api://findings-ledger,api://integrations, andapi://reachgraph.
Services must verify aud and sender constraint (DPoP/mTLS) per their policy.
First-party audience convergence (drift self-heal). The per-boot standard-plugin bootstrapper only reconciles clients listed in standard.yaml bootstrapClients; stella-ops-ui is not in that list, so its audiences are not re-merged from config on subsequent boots. When the canonical audience set grows (e.g. stellaops added to stella-ops-ui), long-lived volumes seeded under the old set would otherwise stay drifted — and a drifted gateway-fronted UI client breaks the whole console (IDX10214 → anonymous principal). The forward-only startup migration 007_authority_first_party_audience_convergence.sql reconciles this: it performs an additive union-merge of the configured first-party audiences into authority.clients.properties.audiences (it never removes audiences) so a drifted or never-converged volume heals on boot. Limitation: the migration runner only applies migrations not already recorded in authority.schema_migrations, so a drift introduced after 007 is recorded is not re-healed by a plain restart; re-converging such a volume requires re-running the reconcile (the migration’s intent is the historical/never-converged volume, not post-migration tampering).
4.2 Core scopes
Authoritative source. The complete, canonical scope catalog is the C# consts on
StellaOpsScopes(src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs). OpenIddict registers the standard OIDC scopes (openid,profile,offline_access) concatenated withStellaOpsScopes.All(Program.cs:393-403), so those four standard scopes plus everyStellaOpsScopesconst are the strings that may legitimately appear in ascopeclaim (the developer/viewer allow-lists inAuthorizeEndpoint.csexplicitly include the standard OIDC scopes). The table below is a curated subset; when in doubt, the consts win. Note the prevailing convention is colon-delimited scope names (signer:sign,scanner:scan,vex:read); a minority of older scopes use dots (vex.admin,concelier.merge,notify.viewer,export.viewer,analytics.read,ops.health,packs.read,registry.admin).
| Scope | Service | Operation |
|---|---|---|
signer:sign / signer:read / signer:rotate / signer:admin | Signer | DSSE signing, key metadata, rotation, admin |
attest:create / attest:read / attest:admin | Attestor | Create/read attestation records, admin |
scanner:scan | Scanner.WebService | Submit scan jobs |
scanner:export | Scanner.WebService | Export SBOMs |
scanner.signed-sbom-material.write | Scanner.WebService | Write signed-SBOM material |
scanner:read | Scanner.WebService | Read catalog/SBOMs |
scanner:write | Scanner.WebService | Update scanner settings |
policy:preview:invoke | Policy Engine | Invoke the isolated Scanner Policy-owner preview contract |
vex:read / vex:ingest / vex.admin | Excititor | Query / raw ingest / operate |
advisory:read / advisory:ingest | Concelier | Read / raw advisory ingest |
concelier.jobs.read / concelier.jobs.trigger / concelier.merge | Concelier | Read job/exporter state / trigger jobs / manage merges |
ui.read / ui.admin | UI | View/admin |
authority:tenants.read / authority:tenants.write | Authority | Tenant catalog admin |
authority:users.read / authority:users.write | Authority | User admin |
authority:idp.read / authority:idp.write | Authority | Read / mutate global identity-provider configuration |
authority:roles.read / authority:roles.write | Authority | Role/scope admin |
authority:clients.read / authority:clients.write | Authority | Client admin |
authority:tokens.read / authority:tokens.revoke | Authority | Token inventory and revoke |
authority.revocation.read | Authority | Revocation bundle status metadata |
authority.audit.read | Authority | Audit log read (note: dot, authority.audit.read) |
authority:branding.read / authority:branding.write | Authority | Branding admin |
zastava:read / zastava:trigger / zastava:admin | Zastava | Read observer state / trigger / admin |
The shared resource-server helpers combine configured default scopes with endpoint scopes by default. RequireStellaOpsScopesWithoutResourceDefaults is the narrowly audited exception for an explicitly isolated machine capability: it retains normal authentication, tenant validation, scope evaluation and audit emission, while preventing a broad resource default from becoming an additional grant prerequisite. Architecture conformance currently permits it only on Policy’s four Scanner owner routes; direct production construction of the opt-out requirement is prohibited.
Note on legacy
authority.*.manageconsts.StellaOpsScopesstill definesauthority.users.manageandauthority.clients.manage(dotted, singularmanage) for backward compatibility. The live admin surfaces gate on the granularauthority:users.read/writeandauthority:clients.read/writescopes instead.
Note — resource-scoped delegation is not a scope. Some capabilities are delegated per resource rather than granted as a tenant-wide scope. The ReleaseOrchestrator deployment share grant is the canonical example: it lets an operator hand a colleague a single deployment’s operate/deploy capability, bound to the recipient’s authenticated account and scoped to that one deployment (or release + environment). It reuses the existing
orch:operate/release:publish/release:readscopes verbatim and adds no new entry toStellaOpsScopes, no new token, and no change to the identity envelope — the elevation is enforced inside ReleaseOrchestrator against the request principal (a native-scope-OR-active-accepted-grant check). Do not add a global scope for a capability that is inherently per-resource. See ADR-027 and the ReleaseOrchestrator share-grant model.
Roles → scopes. The per-tenant authority.permissions/role_permissions catalog is RBAC-editor state only and is not consulted at issuance (see § 4.3 — that claim still holds). However, the interactive /authorize grant does perform role-based scope narrowing at issuance via ResolveUserGrantedScopes, so the older claim that issuance is entirely role-blind is false:
- admin-family roles (
admin,role/console-admin,console-admin,role/console-superadmin,console-superadmin) pass all requested scopes through unchanged; - Executive Assurance Viewer, Release Operations Steward, Security Risk Manager, and Service Release Contributor are intersected with the professional-role bundles in
AuthorityInteractiveRoleGrantCatalog; the Console catalog and authorization-code issuer consume the same lists; developer/release-developer-family roles are intersected withDeveloperScopeAllowList;viewer-family roles are intersected withViewerScopeAllowList;- a principal with no recognized role falls through to all requested scopes unchanged.
Two consequences worth pinning down:
- This narrowing is code-only RBAC (compiled role→scope allow-lists), a second source of truth alongside the deliberately issuance-inert
authority.roles/role_permissionscatalog — operators cannot see or edit it. - It is applied only in the interactive
/authorizepath. The password and client-credentials grant handlers do not apply the same narrowing, so the same user can receive differently-scoped tokens depending on the flow. (See Architecture concerns.)
Executive Assurance Viewer is limited to the canonical assurance read bundle (ui.read, release/policy/findings/evidence/graph reads, policy/audit review, export viewing, deterministic replay verification through replay:read, tenant Timeline reads, platform context, and — BAZ-3 — attestor evidence-transparency / proof-chain reads through attest:read). It does not receive replay:write, vuln:operate, or the attestation-authoring attest:create. Security Risk Manager is limited to the canonical risk-analysis bundle (UI, Scanner read/scan, SBOM and registry inventory reads, findings/Vulnerability Explorer reads and investigation, policy read/simulation, evidence/graph, advisory, raw VEX evidence through vex:read, VexHub statement reads through the distinct vexhub:read claim, tenant Timeline reads, and — BAZ-3 — the remediation console read surface through remediation:read; the remediation:submit/remediation:manage write scopes stay admin-only). VexHub administration remains excluded. Its registry:read claim is limited to tenant-scoped inventory reads; registry cache/plan controls and integration writes remain excluded. Release Operations Steward and Service Release Contributor also carry timeline:read because their Console audit route is a tenant-scoped operational read; this does not grant Timeline writes, export initiation, retention administration, or cross-tenant access. Authorization-code issuance strips requested administrative, release write/publish, orchestration operate, Scheduler operate, policy approval, and exception approval scopes from both roles because those scopes are outside their shared Console-catalog bundles.
The Console Users update path treats every role returned by compiled GetDefaultRoles() as assignable even when no duplicate tenant-local role-table row exists. The compiled catalog remains authoritative for those built-in scope bundles; tenant-created roles must still resolve to persisted role rows. This matches the role-list/editor contract and avoids rejecting a built-in role that was already stored in user metadata and honored during token issuance.
The Scheduler portion of the professional-role contract is intentionally claim-driven and least-privilege:
| Interactive role | Scheduler scope claim values that may be issued | Console contract |
|---|---|---|
Release Operations Steward (role/release-operations-steward) | scheduler:read, scheduler:operate | May see Scheduler read surfaces and operating controls. |
Service Release Contributor (role/service-release-contributor) | scheduler:read only | May see Scheduler read surfaces; operating controls must remain absent or unavailable and scheduler:operate is stripped even when requested. |
StellaOpsScopes.cs remains the canonical claim catalog. ASP.NET policy names inside JobEngine may be dot-delimited (for example scheduler.schedules.read), but those names are not JWT scope values. Scheduler’s Read policy maps to scheduler:read and its Operate policy maps to scheduler:operate; orch:read and orch:operate are separate Release Orchestrator claims and are never aliases for those Scheduler policies. The separate /api/v1/jobengine compatibility read policy accepts either scheduler:read or orch:read, but it does not grant access to Scheduler schedule/run surfaces. Web Scheduler visibility and action enablement must therefore project the issued scheduler:* claims, not infer access from a role label or an orch:* claim.
4.3 authority.permissions vs. JWT scope claims (Sprint 20260512_027)
Two surfaces, two different jobs. They are easy to conflate but enforce very different things, and confusion has produced incorrect operator expectations (e.g. the MEMORY note that claimed “Authority bootstrap creates 149 permission rows” — actual count on the live dev stack: 10 until Sprint 20260512_027 landed).
| Surface | Defines | Read at | Source of truth |
|---|---|---|---|
StellaOpsScopes (C# consts in StellaOps.Auth.Abstractions) | The complete set of scope strings any JWT may legitimately carry in its scope claim. | Every API request, via JWT validation and per-endpoint RequireScope filters. | src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs |
authority.permissions (Postgres table) | The set of scope names the Console Admin role editor will offer when an operator builds or edits a custom role. Each row is a (tenant_id, name, resource, action, description) tuple. | Console role-editor operations only (ListRoles, CreateRole, UpdateRole) and audit before-state snapshots. Not consulted during token issuance or request authorization. | S001_v1_authority_operational_baseline.sql preserves the historical S001-S033 seed lineage; fresh scope top-ups must be new forward S0xx_*.sql files after that collapsed sequence (for example S034_registry_read_scope.sql). The catalog is kept current by appending a seed row whenever a new const is added to StellaOpsScopes. |
Token issuance path (AuthorizeEndpoint.TryAuthenticateAndSignIn):
ApplyUserGrantedScopes(principal, request.GetScopes()); // role-narrowed SetScopes
ResolveUserGrantedScopes narrows the requested scopes by the principal’s role family (see the Roles → scopes note above) before they are set; the client’s allowed_scopes still bound what may be requested. The authority.permissions table is never joined by this path. Token validation at resource servers also operates purely on the JWT’s scope claim.
Console role editor path (ConsoleAdminEndpointExtensions.cs:1236):
var permissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, ...);
The Console UI surfaces authority.permissions rows so operators can build roles by clicking checkboxes rather than typing scope strings. The Console writes authority.role_permissions bridge rows on save, but those bridge rows are still not consulted at JWT-issuance time — they are RBAC catalog state, not enforcement state.
Why seed the full StellaOpsScopes.All set into the table? Operator UX. (As of 2026-05, StellaOpsScopes defines ~178 active consts and grows over time; the collapsed S001 baseline carries the historical full-scope seed, with later S0xx migrations topping up newer scopes.) Without it, the Console role editor only offers the 10 legacy demo names (releases:manage, policy:manage, etc. — none of which match a real scope const), so any operator who wants to build a custom role with policy:approve or vuln:investigate has to either type the scope string blind (no validation) or first POST a permissions row via the API. Seeding the table eliminates that friction.
Why is StandardPluginBootstrapper.EnsureAdminRoleAsync still required? Clean installs no longer rely on the demo seed for the real admin. The standard plugin’s defaultAdminBootstrap path creates or repairs the admin user from STELLAOPS_ADMIN_PASS and then runs EnsureAdminRoleAsync to create any missing permission catalog rows for StellaOpsScopes.All, the admin role, and the role assignment. Seed migrations remain the forward-only catalog history for demo/upgrade paths; runtime bootstrap keeps a seed-off install usable.
Idempotency. Seed permission rows are ON CONFLICT (tenant_id, name) DO NOTHING. The IDs are deterministic so re-running on a populated DB is a zero-row-change no-op. Custom permissions an operator creates through POST /api/v1/console/admin/roles get fresh GUIDs and are not affected by replays.
Concelier jobs-scope startup convergence. (Historically labelled “Trivy/Concelier” — the scopes were introduced for the Trivy DB export surface, which was retired 2026-08-09; the concelier.jobs.* scopes remain live for the surviving Concelier export/job surfaces.) S042_concelier_jobs_read_scope.sql remains the Seed-category catalog history, but Seed migrations are optional when Authority:Bootstrap:Enabled=false. The numeric startup migrations therefore close the install/runtime gap independently: 017_authority_stella_ops_ui_concelier_jobs_read_scope.sql reconciles only the Console client’s read/trigger allow-list, while 018_authority_concelier_jobs_permission_convergence.sql ensures the two canonical permission rows and grants them only to the existing default/admin role. Migration 018 intentionally does not modify clients, users, professional-role issuance allow-lists, other roles, or other tenants; the standard plugin still creates/repairs a missing clean-install admin later in its own bootstrap flow. An admin-family interactive token can be kept read-only for authorization proof by requesting only concelier.jobs.read; normal Console configuration requests both scopes so the administrator can run an export, and Concelier still rejects POST unless the issued token actually carries trigger.
Identity-provider scope convergence. S043_authority_identity_provider_scopes.sql records the canonical authority:idp.read and authority:idp.write permission rows in seed history. Because seed migrations are optional, startup migration 020_authority_identity_provider_scope_convergence.sql independently reconciles those permission rows, grants them only to the existing default/admin role, and additively merges both scopes plus the stellaops audience into the first-party stella-ops-ui client. Operator-added scopes and audiences are retained; unrelated clients, roles, and tenants are unchanged. Platform’s anonymous /platform/envsettings.json response is the live Console OAuth request source, so its built-in default and every Compose Platform__EnvironmentSettings__Scope value must request both scopes as well as the Authority client allowing them. The resource policies reference the canonical constants independently, so a token carrying only authority:idp.read cannot satisfy the write policy.
Adding a new scope. When you add a new const to StellaOpsScopes, append a corresponding row to a fresh forward S0xx_*.sql seed migration after S001_v1_authority_operational_baseline.sql; do not edit the collapsed baseline or archived pre-1.0 seed files. StellaOpsScopeSeedParityTests enforces that every scope const has a seed row.
5) Storage & state
- Configuration & state DB — PostgreSQL only (schema
authority; no MySQL backend exists). The schema is created forward-only by embedded migrations inStellaOps.Authority.Persistence(001_v1_authority_baseline.sqlonward — the pre-1.0 chain was collapsed into that baseline; the old per-feature files are archived underMigrations/_archived/pre_1.0/and excluded from embedding), applied on startup per AGENTS.md §2.7. Core tables:tenants,users,roles,permissions,role_permissions,user_roles— tenant/identity/RBAC catalogclients,service_accounts,api_keys— machine and human client registrations (incl.certificate_bindingsJSONB for mTLS)tokens,refresh_tokens,sessions— issued-token / session metadata and revocation recordsoidc_tokens,oidc_refresh_tokens— existing OpenIddict token/handle persistence. AUTH-16 code/refresh identity and atomic redemption useoidc_tokens(§3.2a–3.3); no new token store or migration is introduced.revocations,revocation_export_state— revocation entries and signed-bundle export cursorlogin_attempts— auth protocol state for lockout/rate-limit (explicitly not an audit table)bootstrap_invites— first-run invite tokensverdict_manifests— VEX verdict manifests for deterministic replay- Tenant-isolated tables enforce PostgreSQL row-level security keyed on the
app.tenant_idsession variable. - The deprecated local audit tables (
authority.audit,authority.airgap_audit,authority.offline_kit_audit) are created and then dropped inside001_v1_authority_baseline.sql(DROP TABLE IF EXISTS … CASCADE, lines 687-689 — the drop was folded in from the pre-1.0002_drop_deprecated_audit_tables.sql); audit now lives in the Timeline unified store (timeline.unified_audit_events).
ADR-039 P13 retention classification. Authority’s source lineage declares 25 authority.* tables: 23 in 001, identity_provider_configs in 019, and break_glass_events in 025. A fresh chain has 22 extant tables because 001 drops the three deprecated local-audit tables above; this is a lineage census, not a claim that 25 tables are live. Forward migration 026_authority_p13_retention_class_headers.sql adds a retention-class: header to every declaration still missing one and conditionally handles a deprecated table retained by an upgraded estate. S050_authority_client_tenants_p13_comment_convergence.sql runs after the applied S001 seed baseline so that baseline’s explanatory client_tenants comment cannot strip the header. Neither applied baseline is edited, and existing table-comment prose remains after the header.
The classified dispositions are:
P13 source-of-truth: tenant/identity/RBAC, clients and service accounts, API keys, bootstrap invites, revocations/export cursor, login lockout state, verdict manifests, and identity-provider configuration;
P13 cache-bounded: issued token, refresh-token, session, and OIDC token/refresh-token state, each bounded by its expiry/revocation lifecycle;
P13 append-audit:
break_glass_eventsand, only when present on an upgraded estate, the three deprecated local-audit tables. Timeline owns all new canonical audit writes; the conditional comments do not revive local writers.Cache (Valkey):
- DPoP jti replay cache (short TTL)
- Nonce store (per resource server, if they demand nonce)
- Rate-limiting buckets
JWKS: key material in file/HSM/KMS or encrypted at rest; JWKS served from memory via the custom
/jwkshandler.
Global identity-provider configuration. Forward-only startup migration 019_authority_identity_provider_configs.sql creates authority.identity_provider_configs for process-global external-provider enablement and configuration. The table intentionally has no tenant_id and no tenant RLS; IIdentityProviderConfigStore reaches it only through AuthorityDataSource.OpenSystemConnectionAsync. Provider names are globally unique through ux_authority_idp_configs_name, and callers supply the creation and update actor and UTC timestamp fields explicitly.
The legacy Platform import is gone (SPRINT_20260722_016 AUTH-4, X7). Authority used to run a startup reconciler that opened the Platform database, read platform.identity_provider_configs, and collapsed those per-tenant rows into its own process-global table. authority.identity_provider_configs has been the store since migration 019/020, the import wrote INSERT … ON CONFLICT (name) DO NOTHING, and Platform has no writer for the legacy table — so on any installation that had booted once the reconciler was a no-op that only re-reported SkippedExisting. It also made the Platform database a startup dependency of the auth root: the hosted service caught only import-conflict exceptions, so an unreachable Platform database failed Authority’s startup outright.
There is therefore no Authority:IdentityProviderImport:* configuration any more, and the compose stacks no longer set STELLAOPS_AUTHORITY_AUTHORITY__IDENTITYPROVIDERIMPORT__PLATFORMCONNECTIONSTRING. Authority reads and writes identity-provider configuration only through Authority:Storage:ConnectionString. platform.identity_provider_configs survives as a frozen, unread legacy table; its physical removal remains a separate operator-approved destructive window under ADR-004.
The process-global operator API is rooted at /console/admin/identity-providers. It is deliberately a sibling of the tenant-scoped /console/admin/* group, so it does not accept or require X-StellaOps-TenantId. Every route requires ui.admin, a global-admin claim or persisted global-admin row, and either authority:idp.read or authority:idp.write. Mutations additionally require auth_time no older than five minutes (with one minute of clock skew) and emit unified Authority audit events. The built-in standard provider is returned as a read-only record; LDAP, OIDC, and SAML rows are mutable, but their provider type is immutable.
Create and update accept provider-specific JSON while rejecting inline values for sensitive fields; operators must use env:, file:, vault:, or secret: indirection. POST .../{name}/apply hot-reloads the descriptor source and returns the explicit {configApplied,bundleMounted,admitted,loaded,healthy, outcome,error} status. POST .../test-connection does not persist the candidate: it resolves the provider-specific probe from the already mounted and admitted signed bundle, keeping LDAP/OIDC/SAML protocol code out of the host. Apply failures use non-2xx responses; a missing bundle is never reported as a successful apply. When the configuration store is unreachable, every route on this surface — reads and mutations alike — returns a typed 503 with code authority.idp.store_unavailable instead of an untyped 500; apply keeps its single typed-status body shape at that code. The complete route × status contract is the curated src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml.
5.1 Runtime binding gates
Production-like runtime must not silently bind fake or process-local persistence:
- Authority storage: tenant, client, token, revocation, and login-attempt stores resolve to PostgreSQL-backed implementations. Generic Authority audit data is served by the Timeline unified audit store (
timeline.unified_audit_events); the deprecated local tablesauthority.audit,authority.airgap_audit, andauthority.offline_kit_auditare dropped by the consolidated baseline migration001_v1_authority_baseline.sql, and the matching persistence repositories are compatibility no-ops only. Store contracts live underStellaOps.Authority.Persistence.Stores; the in-memory namespace contains only Development/Testing implementations and the compatibility driver shim.NullAuthoritySessionAccessoris only registered automatically in Development/Testing. Production may keep the compatibility accessor only through the explicitAuthority:Storage:AllowNullSessionAccessor=truegate while a real ambient session accessor is being introduced. - OpenIddict signing: Development/Testing may use ephemeral token signing keys. Non-local runtime must start with
signing.enabled=true, a stablesigning.activeKeyId, a signing key location insigning.keyPath, and asigning.keySource/signing.providerpair that resolves throughIAuthoritySigningKeySourceplusICryptoProviderRegistry. OpenIddict JWT signing, detached signing, and/jwksmust all resolve the same activekid; providers that cannot export a matching public JWK fail closed rather than falling back to ephemeral keys. Unknown configured providers fail closed, and production-like runtimes rejectsim.crypto.remoteand software-onlycn.sm.softfor Authority signing. Regional deployments must configure a real provider adapter such as SM Remote (cn.sm.remote.http), GOST/CryptoPro/PKCS#11, eIDAS QSCD/TSP, HSM, or KMS-backed signing. - DPoP state: Testing may use in-memory replay/nonce stores. Non-testing DPoP-enabled runtime requires Authority-owned Valkey-backed replay and nonce state with
Authority:Security:SenderConstraints:Dpop:Nonce:Store=valkey(the legacyredisalias is accepted by the runtime) andRedisConnectionStringset. The temporary DPoP bypass flag is honored only in Development/Testing; production-like startup fails if it is enabled. The shared DPoP proof validator also fails closed when noIDpopReplayCacheis supplied, so callers cannot accidentally validate proofs with replay protection disabled. - Auth client token cache: service runtimes must register
AddStellaOpsMessagingTokenCache; CLI/offline clients useAddStellaOpsFileTokenCache.InMemoryTokenCacheis restricted to Development/Testing viaAddStellaOpsInMemoryTokenCacheForLocalRuntime, and hostless consumers fail closed until they choose an explicit cache. - Timestamping:
AddTimestampingdoes not register in-memory TSA cache or artifact timestamp registry by default. Development/Testing harnesses may callAddTimestampingInMemoryStoresForLocalRuntime; production must register durableITsaCacheStoreandIArtifactTimestampRegistryimplementations before using timestamping workflows. As verified on 2026-07-18, no production host callsAddTimestamping, no durable artifact registry exists, and the Authority CI/CD orchestration remains library-only. The bounded verifier now validates CMS signatures independently before applying configured custom roots/intermediates/revocation/time throughX509Chain, and rejects SHA-1 message imprints by default.TsaProviderRegistry.CheckHealthAsyncremains endpoint reachability-only and does not inspect certificate expiry. - IssuerDirectory: the web runtime requires PostgreSQL outside Testing. Direct in-memory repository registration is blocked unless the caller uses the explicit local/test gate. Anonymous mode, where
IssuerDirectory:Authority:Enabled=false, is also restricted to Development/Testing; production-like runtime must authenticate through Authority.
6) Key management & rotation
- Maintain at least 2 signing keys active during rotation; tokens carry
kid. - Prefer Ed25519 for compact tokens; maintain ES256 fallback for FIPS contexts.
- Rotation cadence: 30–90 days; emergency rotation supported.
- Publish new JWKS before issuing tokens with the new
kidto avoid cold-start validation misses. - Keep old keys available at least for max token TTL + 5 minutes.
- Rotate the
kidWITH the key. Never reuse akidfor new key material. This is the rule, not a preference: a resource server caches JWKS bykid, so if the key changes underneath an unchangedkidthe consumer finds the entry it expects, never refetches, and fails signature validation on every token — a hard outage that reads as a signing bug rather than a stale cache. A newkidis a cache miss, and a cache miss is a refetch. Measured on the lab during the 2026-08-21 re-key (SPRINT_20260803_001KEY-2R): withdev-signing-key-1→dev-signing-key-2anddev-ack-key-1→dev-ack-key-2, a freshly minted token passed authentication at a downstream resource server within seconds, while tokens minted under the previouskidreturned 401 — the intended outcome, cleanly separated from a validation fault. Every token minted before the rotation instant is invalid the moment it lands; plan for that rather than discovering it. The two bullets above are how you avoid a gap if you cannot tolerate that: publish the newkidin JWKS first, and keep the old key inAdditionalKeysfor at least max token TTL + 5 minutes. - OpenIddict JWT signing and
/jwksmust resolve the same activekid, algorithm, and public verification key. File, KMS, HSM, or regional providers are supported through the Authority signing source and crypto-provider registry contract when the signer exposes a JWS-compatible signature andExportPublicJsonWebKey()returns a matchingkid/alg/kty. The configured provider name is an explicit production trust root, so Authority must not silently choose another provider whensigning.provideris missing from the registry.
7) HA & performance
Shared persistence for issuance and grant redemption. Token identities and revocation state are persisted; AUTH-16’s one-use code/refresh transition is an atomic database operation shared by Authority replicas, not process-local state.
Resource servers can validate signed JWTs locally, but Authority issuance, introspection, revocation and protected-grant redemption depend on their stores. Database availability is therefore part of the protocol’s failure boundary (§12).
Targets:
- Token issuance P95 ≤ 20 ms under warm cache.
- DPoP proof validation ≤ 1 ms extra per request at resource servers (Signer/Scanner).
- 99.9% uptime; HPA on CPU/latency.
8) Security posture
- Strict TLS (1.3 preferred); HSTS; modern cipher suites.
- mTLS enabled where required (Signer/Attestor paths).
- Replay protection: DPoP
jticache, nonce support for Signer (addDPoP-Nonceheader on 401; clients re-sign). - Rate limits per client & per IP; exponential backoff on failures.
- Secrets: clients use private_key_jwt or mTLS; never basic secrets over the wire.
- CSP/CSRF hardening on UI flows;
SameSite=Laxcookies; PKCE enforced. - Logs redact
Authorizationand DPoP proofs; storesub,aud,scopes,stellaops:tenant,cnfthumbprints, not full keys.
8.1 IdP plugin replay protection (Sprint 20260430-014, Pass-2 § H2)
Authority plays two distinct OIDC roles, and replay protection is enforced at two distinct layers:
| Role | Replay layer | Where enforced |
|---|---|---|
| Authority as OIDC IdP (issuing OpToks) | OpenIddict server: PKCE + persisted JTI + one-use redemption | RequireProofKeyForCodeExchange() and TokenValidationHandlers.FindByTokenIdAsync validate the grant. Access-token persistence reads the emitted JWS identity; AUTH-16’s PrepareEncryptedTokenIdentityHandler assigns code/refresh identity before protection, PersistTokensHandler persists it afterward, and RedeemInteractiveTokenHandler performs the atomic one-use transition. See §3.2a–3.3 for the pending-checkpoint boundary. |
| Authority as OIDC RP (consuming external IdP tokens) | OIDC plugin: per-plugin JTI cache | StellaOps.Authority.Plugin.Oidc.Credentials.OidcCredentialStore.VerifyPasswordAsync (oidc:{plugin}:jti:{jti} keyed in shared IMemoryCache, absolute expiration bounded by token exp and OidcPluginOptions.JtiCacheMaxLifetime) |
| Authority as SAML SP (consuming external IdP assertions) | SAML plugin: per-assertion-id cache | StellaOps.Authority.Plugin.Saml.Credentials.SamlCredentialStore.VerifyPasswordAsync (saml:{plugin}:assertion:{id} keyed in shared IMemoryCache, absolute expiration = Conditions.NotOnOrAfter); rejects assertions missing the SAML 2.0 § 2.3.3 ID attribute |
Nonce binding boundary. Nonce binding for the auth-code → ID token flow is provided by OpenIddict’s built-in aspnet-core-flows validator. The authorization and token endpoint URIs are bound via SetAuthorizationEndpointUris("/authorize", "/connect/authorize") and SetTokenEndpointUris("/token", "/connect/token") (Program.cs:360-361) with EnableAuthorizationEndpointPassthrough() (Program.cs:413) — there are no MapAuthorizationEndpoint/MapTokenEndpoint calls in src/Authority (that API does not exist here). The OIDC plugin does not perform nonce binding — by the time the plugin’s VerifyPasswordAsync sees a token, the auth-code-flow caller (Authority gateway acting as RP, or a CLI/agent presenting a token directly) has already satisfied any nonce contract upstream. The plugin’s job is signature/issuer/ audience validation plus the JTI replay cache described above.
LDAP plaintext deprecation. LdapSecretResolver accepts env:NAME and file:/path indirection in any environment but rejects plaintext literal secrets outside Development (throws InvalidOperationException at startup with a sprint reference in the message). Operator runbooks must use env:LDAP_BIND_PASSWORD (Vault-backed) or file:/run/secrets/ldap-bind. See SPRINT_20260430_014 and docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md § H2.
8.2 Resource-server bypass networks — what a trusted CIDR may and may not grant (W3-B, Sprint 20260712_008)
Authority:ResourceServer:BypassNetworks (bound by every service that calls AddStellaOpsResourceServerAuthentication) lets a request from a listed CIDR proceed without an Authorization header. It is scoped by Authority:ResourceServer:BypassMode (StellaOps.Auth.ServerIntegration/StellaOpsBypassMode.cs).
The trap it was built on. The Router gateway strips Authorization before proxying (IdentityHeaderPolicyMiddleware.cs — "Authorization" is in ReservedHeaders) and re-asserts the caller in an HMAC-signed identity envelope, which AddStellaOpsResourceServerAuthentication bridges back into an authenticated principal (ServiceCollectionExtensions.cs, OnMessageReceived). Gateway-proxied user traffic therefore arrives header-less from the compose subnet — the exact shape the bypass was written to trust. Before this sprint a listed CIDR force-satisfied every requirement (StellaOpsScopeAuthorizationHandler.cs: scopes, the tenant allow-list, and the fresh-auth / dual-control gates), so any authenticated user whose token merely lacked a scope was handed that scope by their network position. The bypass was an authorization bypass, not the authentication convenience it was documented as.
Contract now (BypassMode, default Strict). Network position substitutes for a missing identity; it never adds authority on top of an asserted one.
| Caller | Strict (default) | Legacy | Disabled |
|---|---|---|---|
Bearer/DPoP token (has Authorization) | never bypassed (unchanged) | never bypassed | never bypassed |
| Gateway identity envelope present | never bypassed — envelope scopes/tenant are authoritative; an envelope with no scopes authorizes nothing | bypassed | never bypassed |
| Authenticated principal missing a scope, or carrying a disallowed tenant | refused (403) | bypassed | refused |
Step-up / dual-control requirement (decision-signing, authority:signing-keys.enroll, authority:signing-keys.admin, packs.approve, obs:incident, orch:backfill) | never bypassed, even anonymously — an IP address cannot be the operator these gates demand | bypassed | never bypassed |
| Anonymous, token-less, envelope-less call from a listed CIDR | bypassed (deliberate residual — see below) | bypassed | refused |
The deliberate residual. Strict still lets an anonymous, envelope-less caller from a listed CIDR satisfy an ordinary scope policy, because documented intra-stack calls depend on it — advisory-ai → scanner /api/v1/security/findings, release-orchestrator → scanner, vexhub/scheduler → excititor (see the comments at devops/compose/docker-compose.stella-services.yml around the BYPASSNETWORKS keys). Closing it means giving those callers client-credentials tokens; until then any workload that can reach a service on the backend bridge can still exercise its non-step-up scoped endpoints anonymously. Treat the compose subnets as a trust boundary accordingly. Disabled is the end-state and is already safe to select for any service with no token-less internal callers.
Tenancy is unaffected by a bypass. A bypassed caller has no principal, so StellaOpsTenantResolver (claim-first) yields tenant_missing and any .RequireTenant() endpoint returns 400 — authorization may pass, tenancy still refuses. Do not read a tenant from a raw header to “fix” this; that reintroduces the forgeable-header vector on precisely the bypass network.
9) Multi-tenancy & installations
- The tenant registry (
authority.tenants, claimstellaops:tenant) and an optional project scope (claimstellaops:project) define the isolation boundary a client/user can request. There is no separate “installation” claim in issued tokens. - Cross-tenant isolation enforced at issuance (disallow rogue
aud; the password grant bindsstellaops:tenantto the user’s home tenant and refuses suspended tenants), and resource servers must check thatstellaops:tenantmatches their configured tenant. - Tenant records may carry a nullable
complianceProfilefor DORA, NIS2, and CRA filtering. The profile contract is pinned astenant-compliance-profile.v1and documented indocs/contracts/tenant-compliance-profile-v1.md. Initial persistence stores the normalized profile atauthority.tenants.metadata.complianceProfile; non-regulated tenants leave it absent. Runtime consumers read the Authority source-owned profile throughGET /api/v1/tenants/{tenantId}/compliance-profilewithauthority:tenants.read; the response carries only the nullable profile contract and never operator settings, deployment posture, or secret refs. - Tenant records may carry operator-owned Assurance pack enablement under
authority.tenants.settings.operatorCompliance.assurancePacks. The Authority readiness projection isGET /api/v1/tenants/{tenantId}/assurance-packs; it returns enabled packs, Authority-owned readiness booleans, and stable reason codes. It must never echo sealed signing providers, trust roots, storage roots, public endpoint posture, mailbox credentials, or provider secrets. The route accepts any ofauthority:tenants.read,policy:read, orpolicy:audit; the requiredX-StellaOps-TenantIdheader must still match the path tenant.
9.1 Tenant header contract (header-required, AUTH-DRIFT-003)
Tenant-scoped Authority surfaces enforce the header-required contract on the X-StellaOps-TenantId request header (per AUTH-DRIFT-003 / DRIFT-AUTHORITY-CONSOLE-TENANT-HEADER-OK-001). The bearer’s stellaops:tenant claim is informational only and is not a fallback for tenant resolution.
Resolution rules (encoded in src/Authority/StellaOps.Authority/StellaOps.Authority/Console/TenantHeaderFilter.cs):
- Header missing or whitespace →
400 tenant_header_missing(audited per-endpoint) and the request is short-circuited before the handler runs. Note: the canonical filter passes through to the handler without setting the resolved tenant, and the handler’s null-check produces the 400 along with the endpoint-specific audit event. - Header present + bearer’s allowed-tenants list non-empty + header value not in the list →
403 forbidden. - Header present + bearer’s
stellaops:tenantclaim present + values do not match (case-insensitive) →403 forbidden, unless the caller is a global admin. The global-admin check (TenantHeaderFilter.cs:65-70→ConsoleGlobalAdminResolver.IsGlobalAdminAsync) accepts either thestellaops:admin=trueclaim or the persistedauthority.users.is_global_admin=truerow for the subject — the same claim-OR-row check applied to all tenant-scoped console routes, not onlyGET /console/tenants. - Header present and consistent with the principal → resolved tenant is stored in
HttpContext.Items["__authority-console-tenant"]for the handler. - Anonymous (unauthenticated) requests →
401 unauthorized(auth middleware short-circuits before this filter runs).
Endpoint inventory (tenant-scoped, header-required):
| Group / endpoint | Filter wired by |
|---|---|
/console/profile | ConsoleEndpointExtensions.MapConsoleEndpoints |
/console/tenants | same |
/console/dashboard | same |
/console/filters | same |
/console/vuln/* | same |
/console/vex/* | same |
/console/token/introspect | same |
/console/admin/users/* | ConsoleAdminEndpointExtensions.MapConsoleAdminEndpoints |
/console/admin/roles/* | same |
/console/admin/clients/* | same |
/console/admin/audit/* | same |
/console/admin/branding/* | ConsoleBrandingEndpointExtensions.MapConsoleBrandingEndpoints |
/console/admin/identity-providers/* is intentionally absent from this table: it is process-global, has its own global-admin filter, and never derives a tenant from a request header or token claim.
The /internal/* bootstrap automation tier authenticates with the X-StellaOps-Bootstrap-Key header (not this tenant header, and not an authority.admin scope — that scope does not exist) and is NOT covered by this header. The /.well-known/*, /connect/*, /jwks*, /health, and /ready surfaces are explicitly tenant-agnostic.
The architecture-conformance pack (src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/AuthorityTenantHeaderConsistencyTests.cs) drives an HTTP probe against each tenant-scoped path without the header and asserts the response is in {401, 400, 403, 404} — never 2xx. The endpoint list is locked at ≥4 via a self-test so accidental coverage shrinkage trips a build failure.
The OpenAPI spec for Authority documents X-StellaOps-TenantId as a required parameter on all tenant-scoped operations (src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml).
Resource-server global-admin override
Downstream services that use StellaOpsTenantResolver remain claim-first for ordinary principals: a non-admin bearer cannot switch tenants by sending a tenant header. Sprint 20260530.033 adds a narrow Console-admin exception for principals whose token carries stellaops:admin=true: the resolver accepts the canonical X-StellaOps-TenantId header and reports TenantSource.CanonicalHeader. Legacy tenant headers are still non-authoritative. This is the backend contract for the Console admin tenant switcher and follows docs/architecture/decisions/ADR-023-console-admin-tenant-switch-strategy.md.
GET /console/tenants uses the same global-admin signal for Console tenant switching. Ordinary principals receive only their assigned tenant catalogue. Global administrators receive the full Authority tenant catalogue, built from authority.tenants — the rows Authority itself owns — merged with the configured catalogue for anything the repository does not carry. It is deliberately not read back out of the platform shared.tenants replica (removed by SPRINT_20260722_016 AUTH-4): Authority is the only production writer of that table, so the read was a cross-database round trip for data it already held, and because the replica took precedence a stale display name or status there outranked the owner’s own row. A row present only in shared.tenants is by construction not an Authority tenant — nobody can authenticate into it — so it is not listed. Estate-level consumers read the tenants catalog replica (SPRINT_20260722_027) instead. Any tenant outside the principal’s assignment list is returned with isAdminAccess=true so the Console can render an explicit “Admin access” badge before the operator pivots the canonical tenant header. The Console endpoint accepts either the live stellaops:admin=true claim or the persisted authority.users.is_global_admin=true row for the subject, so an OIDC envelope whose tenant claim has already been rewritten to the requested tenant can still prove the global-admin entitlement without relying on a home-tenant claim.
The persisted lookup enumerates Authority’s actual tenant catalog, then reads users through AuthorityDataSource tenant scopes with FORCE RLS still active. It does not query protected users on a system connection, assume a default home tenant, elevate the database role, or cache an entitlement across requests. An enabled persisted user and is_global_admin=true are both required. When the identity contains a subject, only that subject can establish persisted entitlement; a matching username belonging to another subject cannot confer access. Username lookup is retained only for identities without a subject. The requested resource tenant remains unchanged. ConsoleGlobalAdminPostgresTests exercises the actual filter and directory against a restricted PostgreSQL role, including cross-tenant entitlement, subject/name collision refusal, disabled users, ordinary service identities and reset of pooled tenant context.
Console workspace aggregator (vuln / VEX / dashboard / filters)
The /console/vuln/*, /console/vex/statements, /console/dashboard, and /console/filters read surfaces are served by a real BFF aggregator (ConsoleWorkspaceHttpService, Sprint W4a / SPRINT_20260608_023) that fans out to Findings (/v1/vulns, /v1/vex-decisions) and Scheduler (/api/v1/scheduler/policy/runs). ConsoleWorkspaceSampleService (hard-coded sample data) is kept only for Development / TestingLocalHarness; in any other environment a fail-closed ConsoleWorkspaceRuntimeConfigurationValidator (wired via AddRuntimeConfigurationValidators()) rejects startup if the sample service is still active or the downstream base URLs / service-account secret are missing.
The auth model is the hard part. The gateway strips the user’s bearer for /console(no PreserveAuthHeaders), so the Console has no user token to forward. Instead it:
- Mints a per-tenant
console-workspaceclient-credentials token (ConsoleWorkspaceTokenProvider, lifted from the Scanner worker’sConcelierLearnTokenHandler). The downstream-call tenant comes fromTenantHeaderFilter.GetTenant(httpContext)(already validated against the inbound envelope), and thetenantform param drives the minted token’sstellaops:tenantclaim. The service account is least-privilege: scopesvuln:view scheduler:read policy:run aoc:verify platform:doctor:register, audiencesapi://findings-ledger stellaops api://scheduler, a named-tenant allow-list (never wildcard), secret auto-managed viastandard.yamlbootstrapClients(CONSOLE_WORKSPACE_CLIENT_SECRET, Vaultvault://secret/stellaops/console-workspace#secret). A mint failure (tenant off the allow-list) fails closed to a 502-class — never sample data.platform:doctor:registeris the host-level AUTH-7 grant: Authority’s Doctor registrar must reuse this identity because registering a second auth client would replace the Console token client and fail the host closed. - Relays the gateway-signed operator identity envelope as a verified on-behalf-of sidecar (
X-StellaOps-OnBehalfOf-Envelope/-Signature/-Algorithm) — it does not mint a new envelope (only the gateway mints). Downstream,OnBehalfOfActorMiddleware(StellaOps.Router.AspNet, registered in Findings) cryptographically verifies the relay with the same codec + signing key the dispatcher uses, checks freshness, drops the actor on a tenant mismatch, and stampsHttpContext.Items["stellaops:onBehalfOf"]without replacing the principal (the service token stays the authZ principal; the on-behalf-of actor is audit-only).AuditActionFilterthen records the real operator asActor.Idwithdetails.onBehalfOf.delegate = console-workspace.
A per-panel user-scope gate runs before each downstream call: findings/VEX require the operator envelope to carry vuln:view, run-health requires scheduler:read. A missing scope degrades that tile (empty result, zero outbound call) rather than failing the whole workspace. Console read endpoints carry .Audited(...) so operator read access is recorded as evidence under the real operator (httpContext.User).
The canonical pattern reference (the conformance-migration target for other machine identities) is ADR-029.
Scheduler note: the Scheduler-side
OnBehalfOfActorMiddlewareregistration is deferred to the conformance migration (parallel-WIP). The Console still relays the on-behalf-of envelope on the run-health call (harmless if Scheduler ignores it); the operator-evidence bar for the dashboard read is met by Authority’s own.Audited.
10) Admin & operations APIs
Authority exposes two admin tiers — note the prefixes and auth gates differ from earlier drafts (there is no /admin/* + authority.admin scope tier; the authority.admin.* strings in code are audit event types, not a scope):
/internal/*— bootstrap tier. Gated by theX-StellaOps-Bootstrap-Keyheader (BootstrapApiKeyFilter, matched againstAuthority:Bootstrap:ApiKey) and only mounted whenAuthority:Bootstrap:Enabled=true. Used by the first-run setup wizard and operator automation before interactive auth exists./console/admin/*— Console tier. Bearer + per-endpoint scopes (e.g.authority:tenants.write,authority:users.write,authority:roles.write,authority:clients.write,authority:tokens.revoke,authority:audit.read,authority:branding.write) plus the header-required tenant contract (§ 9.1). A legacy/api/admin/usersalias mirrors the user sub-tier.
Authority Console VEX live streaming is intentionally fail-closed. /console/vex/events requires ui.read and vex:read, but Authority has no durable tenant-scoped VEX event source, so the route returns 501 vex_events_stream_unsupported, emits authority.console.vex.events, and clients must poll /console/vex/statements for deterministic VEX state.
Bootstrap tier (/internal/*, API-key gated):
POST /internal/users # provision a user (setup wizard admin step)
POST /internal/clients # create/update client (confidential/public)
POST /internal/invites # issue a bootstrap invite token
GET /internal/revocations/export # export the signed revocation bundle
GET /internal/service-accounts # list service accounts
GET /internal/service-accounts/{id}/tokens # list a service account's tokens
POST /internal/service-accounts/{id}/revocations # revoke a service account's tokens
POST /internal/signing/rotate # rotate the active signing key (zero-downtime)
POST /internal/notifications/ack-tokens/rotate # rotate notification ack-token keys
POST /internal/plugins/reload # hot-reload identity-provider plugins
Console admin tier (/console/admin/*, scope-gated):
GET /console/admin/tenants POST /console/admin/tenants
POST /console/admin/tenants/{id}/suspend|resume
GET /console/admin/users POST /console/admin/users
POST /console/admin/users/{id}/disable|enable
GET /console/admin/roles POST /console/admin/roles
POST /console/admin/roles/{id}/preview-impact
GET /console/admin/clients POST /console/admin/clients
POST /console/admin/clients/{id}/rotate
GET /console/admin/tokens POST /console/admin/tokens/revoke
GET /console/admin/audit
GET /console/admin/branding POST /console/admin/branding/preview
GET /console/admin/audit is a deprecated compatibility proxy for Timeline’s canonical GET /api/v1/audit/events?modules=authority read. It binds the query to the authenticated Authority tenant, forwards decoded filters, and signs the Authority-to-Timeline hop with a short-lived tenant-bound service identity envelope carrying only timeline:read. The hop fails closed before HTTP when the shared identity-envelope key is absent. Timeline dependency failures are reported as sealed HTTP 502 ProblemDetails with reason=timeline_audit_unavailable; raw upstream exception, database, and credential detail is never returned to the Console.
POST /console/admin/clients/{id}/rotate mints a new 256-bit client secret, persists its hash (plaintext never stored), and returns the secret once. To let statically-configured service clients roll over without an outage it opens a dual-secret grace window: the superseded secret keeps authenticating until a recorded expiry (default 7 days, max 30; graceHours: 0 = invalidate immediately), honored by ValidateClientCredentialsHandler via VerifySecretWithGrace. The window state lives in the client document’s Properties (previousSecretHash / previousSecretExpiresAt) — JSONB, no new schema. Rotation is confidential-clients-only (400 client_secret_not_supported otherwise). Distribution stays operator-driven (Authority never pushes secrets into customer-controlled deploy config/Vault); the full rollout model and the owed live rollover proof are in operations/client-secret-rotation.md.
Tenant creation keeps the pre-flight slug lookup for operator feedback, but the database unique constraint remains authoritative under concurrency. If a concurrent POST /console/admin/tenants wins after the lookup, Authority translates PostgreSQL 23505 into the same 409 tenant_already_exists response instead of surfacing a server error.
Health & metadata (anonymous, tenant-agnostic): GET /health, GET /ready, GET /jwks, GET /.well-known/openid-configuration, GET /.well-known/openapi. (There is no /admin/metrics, /admin/healthz, or /admin/readyz; metrics are exported through the shared platform telemetry pipeline configured in AuthorityTelemetryConfiguration, not a dedicated /admin/metrics route.)
Declared client audiences flow through to the issued JWT aud claim and the token request’s resource indicators. Authority relies on this metadata to enforce DPoP nonce challenges for signer, attestor, and other high-value services without requiring clients to repeat the audience parameter on every request.
The closed StellaOps.Auth.Client contract exposes a non-success token response as StellaOpsTokenRequestException (an InvalidOperationException refinement) with only HttpStatusCode and an allowlisted OAuth/OIDC error code. It never copies the response body, error_description, or an unknown attacker-controlled error into the exception or log. Consumers can therefore treat 408/425/429/5xx and server_error/temporarily_unavailable as retryable while keeping invalid-client/scope/grant configuration terminal, without parsing log text. Malformed successful responses remain a separate invalid-response path.
SGN-7’s Notify-to-Signer client is source-only and default-off. Live activation requires a dedicated confidential client enrolled for the requested tenant with exact audience signer, scope signer:sign, and its client secret. The tracked generic Notify client is not widened by this change, and no grant was installed. The current Auth.Client token request is not itself DPoP sender-constrained, so it cannot satisfy a live cryptographic cnf posture without the planned Authority/gateway closure.
10.1 Tenant-create auto-onboarding & first-party client flag (Sprint F3)
When a new tenant is created via POST /console/admin/tenants, every client flagged as first-party is automatically extended to include the new tenant in its properties.tenants allow-list, so the operator never needs to run the manual UPDATE authority.clients SET properties[Tenants] = … SQL the WS-7 onboarding flow was previously plagued by. The mechanism has three layers:
Declarative source of truth —
authority.clients.is_first_party— aBOOLEAN NOT NULL DEFAULT FALSEcolumn created by the consolidated baseline001_v1_authority_baseline.sql(lines 335/346; it folded in the pre-1.0S032_clients_is_first_party_column.sql). The baseline also creates a partial indexidx_clients_is_first_party WHERE is_first_party = TRUE(line 351) for the runtime auto-grant query.The migration backfills
is_first_party = TRUEfor the known platform-shipped first-party multi-tenant clients:stellaops-cli(operator/QA password grant)stellaops-cli-automation(CI/automationclient_credentials, Pattern B per Sprint 051)stellaops-scanner-worker(Scanner/SBOM workerclient_credentials, Pattern B)stellaops-release-dispatch(cross-tenant release/orch operator, Pattern B per Sprint 059)
Standard-plugin
bootstrapClientsmay also opt a platform client in withfirstParty: true.BootstrapClientOptions.ToRegistrationcarries that flag, and the provisioning UPSERT union-preserves it. This is the path used by newer Pattern-B service clients such asstellaops-findings-security-internal; clients without a migration, YAML flag, or operator opt-in stayFALSE.Runtime resolution —
TenantClientGrantService— when the admin endpoint commits a new tenant row, it callsITenantClientGrantService.GrantAsync(tenantSlug). That service resolves its target client-id set at grant time:- If
Authority:TenantClientGrants:AutoGrantClientIdsis supplied in configuration the operator-supplied list is honoured verbatim (including the empty-list opt-out — for test harnesses or pinned deployments). - Otherwise the service queries
IAuthorityClientStore.ListFirstPartyAsync(), which scans the partial index and returns every row withis_first_party = TRUE. Each matched client’sProperties[Tenants]space-separated allow-list is union-extended with the new tenant slug (idempotent — re-grants are no-ops; never shrinks the allow-list).
- If
Operator extension contract — operators can mark their own custom clients first-party via:
UPDATE authority.clients SET is_first_party = TRUE WHERE client_id IN ('acme-custom-automation', '...');…and the very next
POST /console/admin/tenantsrequest will auto-extend them. No service rebuild, noappsettings*.jsonedit, no Authority restart is required. The decision lives next to the client definition.When adding a new platform-shipped first-party client, declare
firstParty: truein its authoritative Standard-plugin bootstrap entry (or use a forward-only migration for a non-bootstrap client). The runtime auto-grant default expands on the next tenant-create operation without a code change.
Operator override on UPSERT. The Standard plugin re-runs CreateOrUpdateAsync from standard.yaml on every Authority startup. The EF UPSERT on authority.clients uses is_first_party = authority.clients.is_first_party OR EXCLUDED.is_first_party, which guarantees an operator-set TRUE on a custom client survives YAML re-applies. A bootstrap entry can set firstParty: true; the union means neither that declarative value nor an operator-set value can be cleared by a later reconciliation.
The singular plan assignment deliberately does not use that union rule. Committed bootstrap configuration is authoritative in both directions: adding plan stores the normalized value, while removing it deletes the client property on the next reconciliation so a stale elevated assignment cannot survive configuration removal.
Inclusion rule for new platform clients. A client should be marked is_first_party = TRUE iff ALL of:
- it is multi-tenant by design (Pattern B: no singular
tenantdescriptor,tenantsis plural); - it ships with the platform (first-party, not operator-installed); AND
- newly created tenants must be usable by that client without a manual grant;
- it is not a dev-only harness.
This includes both operator-facing automation and narrowly scoped internal service identities. It does not relax their grant, audience, scope, or requested tenant validation.
See docs/runbooks/general/tenant-onboarding.md for the full operator runbook and SPRINT_20260531_F3_Authority_first_party_clients_auto_onboard.md for the class-of-bug history that motivated the declarative shape.
10.2 Typed client-tenants link table (Sprint 036 / D1.3)
The space-separated properties.tenants string described above is the back-compat mirror. The authoritative source of truth after Sprint 20260531.036 is the link table authority.client_tenants, created by the consolidated baseline 001_v1_authority_baseline.sql (line 355; it folded in the pre-1.0 S033_client_tenants_link_table.sql):
CREATE TABLE authority.client_tenants (
client_id TEXT NOT NULL REFERENCES authority.clients(client_id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL CHECK (tenant_id <> ''),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by TEXT,
PRIMARY KEY (client_id, tenant_id)
);
CREATE INDEX idx_client_tenants_tenant ON authority.client_tenants(tenant_id);
The migration backfills one row per (client_id, tenant) pair derived from every existing properties.tenants string, tagged granted_by = 'migration:S033'.
Why the link table is the structural fix to the D1.3 class-of-bug:
- The string-form has NO database integrity (no FK to
authority.tenants). - It cannot carry grant audit metadata (who, when, why).
- Every reader has to string-split + dedup on every load.
- Race windows on the JSON UPDATE silently overwrite concurrent grants.
The link table closes all four gaps in one place. The string form remains populated as a back-compat mirror for one release cycle so existing readers (token issuance handlers, the Standard / LDAP plugin provisioning stores) keep working unchanged; a follow-up sprint removes the string and all string-side consumers.
Reader contract (ClientEntity.Tenants):
ClientRepository.FindByClientIdAsync and ListAsync now populate the typed IReadOnlyList<string> ClientEntity.Tenants from the link table on every load (one extra query per call; trivial cost since clients are short-lived). Consumers should prefer entity.Tenants over the legacy entity.Properties[AuthorityClientMetadataKeys.Tenants] string — Tenants is sorted ascending by tenant_id for deterministic audit-log output.
Writer contract (ClientRepository.ReplaceTenantsAsync):
A new atomic per-call API replaces the entire tenant assignment set for a client:
await clientRepo.ReplaceTenantsAsync(
clientId: "stellaops-cli-automation",
tenants: new[] { "default", "e2e-lab", "partner-dev-lab" },
grantedBy: "ITenantClientGrantService.GrantAsync");
UpsertAsync mirrors Properties[tenants] (or the explicit Tenants list when populated) into the link table on every write, so existing provisioning paths that still emit the legacy string keep both sources in sync without code change.
Graceful degradation: if the link table is missing (e.g. transitional deploy where the consumer image is rolled before migration S033 has run), both paths log a one-shot warning (authority.client_tenants table missing — falling back to Properties[tenants] string) and continue from the string mirror.
See SPRINT_20260531_036_Policy_findings_lookup_release_components_pkg_join_class_d1.md for the full D1 sweep (D1.1 ecosystem-aware findings join + D1.3 typed link table).
11) Integration hard lines (what resource servers must enforce)
Every Stella Ops service that consumes Authority tokens must:
Verify JWT signature (
kidin JWKS),iss,aud,exp,nbf.Enforce sender-constraint:
- DPoP: validate DPoP proof (
htu,htm,iat,jti) and matchcnf.jkt; cachejtifor replay defense; honor nonce challenges. - mTLS: match presented client cert thumbprint to token
cnf.x5t#S256.
- DPoP: validate DPoP proof (
Check scopes; optionally map to internal roles.
Check tenant (
stellaops:tenant) and project (stellaops:project) as appropriate.For Signer only: require both OpTok and PoE in the request (enforced by Signer, not Authority).
12) Error surfaces & UX
- Token endpoint errors follow OAuth2 (
invalid_client,invalid_grant,invalid_scope,unauthorized_client). - Resource servers use RFC 6750 style (
WWW-Authenticate: DPoP error="invalid_token", error_description="…", dpop_nonce="…"). - For DPoP nonce challenges, clients retry with the server-supplied nonce once.
- AUTH-16 storage failures:
AuthorityTokenStorageFailureMiddlewarecovers the exact authorize, token, introspection and revocation protocol paths and their registered/connect/*forms. Classified transient PostgreSQL failures return HTTP 503; a typed external-operation timeout, including one wrapped by a transient PostgreSQL exception, returns HTTP 504. Both use OAuthtemporarily_unavailable,Retry-After: 5,Cache-Control: no-storeand a credential-safe description, with no successful token response. Unexpected defects and non-transient SQL/schema failures retain normal server-error handling (500), not a misleading retryable availability classification. Caller cancellation is not converted into a storage timeout, and a response already committed is aborted rather than appended to. Re-verify withRealProtectedGrant_StorageFailure_EmitsSealedRetryableErrorAndNoTokens,ProtocolEndpoint_TransientStorageFailure_IsSanitized503,TypedExternalTimeout_IsSanitized504, andNonAvailabilityException_IsNotMisclassifiedAsRetryable; live acceptance remains pending under §3.2a.
13) Observability & audit
Metrics (emitted on the
StellaOps.Authoritymeter —AuthorityTelemetry.MeterName, registered viaAuthorityTelemetryConfiguration). The metric names below are the ones actually instrumented in source (verified againstsrc/Authority/StellaOps.Authority/StellaOps.Authority/):authority_dpop_nonce_miss_total— DPoP nonce challenges raised for missing/invalid proofs (OpenIddict/Handlers/DpopHandlers.cs).authority_mtls_mismatch_total— mTLS-bound token requests rejected for missing/mismatched certificates (OpenIddict/Handlers/TokenValidationHandlers.cs).
Roadmap (not yet implemented). Earlier drafts listed dot-delimited counters
authority.tokens_issued_total{grant,aud},authority.dpop_validations_total{result},authority.mtls_bindings_total{result},authority.jwks_rotations_total, andauthority.errors_total{type}. None of these names exist in code today — the Authority service registers no token-issuance, JWKS-rotation, or generic-error counters beyond the two*_totalcounters above. Treat them as proposed instrumentation, and note the prevailing convention for any new Authority metric is underscore-delimited (authority_<name>_total), not the dotted form used in those drafts. Token-issuance, revocation, and admin-change events are nonetheless captured in the audit log (next bullet); Span/activity tags (e.g.authority.token.validate_dpop,authority.sender_constraint,authority.dpop_result) are emitted on theActivitySourcefor tracing.Audit log (the Timeline unified sink
timeline.unified_audit_events, not a local Authority table): token issuance (sub,aud,scopes,stellaops:tenant,cnf thumbprint,jti), revocations, admin changes.Compliance profile audit attributes: tenant create/update/suspend/resume and tenant-read console audit events include
tenant.compliance_profile.*properties when available so offline regulator filtering can identify DORA, NIS2, and CRA tenant scope without live registry lookups.authority.resource.authorizecardinality (1:1 contract). Every protected request — including those guarded by overlappingRequireStellaOpsScopes(...)policies on a route group and the matching endpoint — emits exactly oneauthority.resource.authorizeaudit event, fired after the authorization decision is finalized. Although ASP.NET Core invokesStellaOpsScopeAuthorizationHandleronce perStellaOpsScopeRequirement(so a request gated by a group-level requirement plus an endpoint-level requirement triggers the handler twice), a request-scoped marker onHttpContext.Items["authority.audit.authorize.emitted"]suppresses subsequent emissions. Defense-in-depth: any future audit emission path for this event must check / set this key before writing. Architecture-conformance:src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/AuthorityAuditEventCardinalityTests.csdrives 50 protected requests through a TestServer-hosted WAF and asserts the resulting event count is exactly 50 (and a parallel test asserts a single event for an endpoint guarded by overlapping group + endpoint policies). SeeDRIFT-AUTHORITY-CONSOLE-DOUBLE-AUTHORIZE-001.Plugin telemetry: password-capable plug-ins (Standard, LDAP) emit
authority.plugin.<name>.password_verificationevents viaIAuthEventSink, inheriting correlation/client/tenant/network metadata fromAuthorityCredentialAuditContext. Each event includesplugin.failed_attempts,plugin.lockout_until,plugin.retry_after_seconds,plugin.failure_code, and any plug-in specific signals so SOC tooling can trace lockouts and rate-limit responses even in air-gapped deployments. Offline Kits ship the plug-in binaries plus the curated manifests (devops/etc/authority.plugins/*.yaml) so these audit flows exist out of the box.Tracing: token flows, DB reads, JWKS cache.
14) Configuration (YAML)
Illustrative, not the live bootstrap schema. The
clients:block below is a conceptual reference. The actual first-party client bootstrap file (devops/etc/authority/plugins/standard.yaml, § 1) uses a different shape —bootstrapClients:withallowedGrantTypes/allowedScopes/allowedAudiencesstring fields,confidential,requirePkce, etc. Use § 1 for the real schema.
authority:
issuer: "https://authority.internal"
signing:
enabled: true
activeKeyId: "authority-signing-2025"
keyPath: "../certificates/authority-signing-2025.pem"
algorithm: "ES256"
keySource: "file"
security:
rateLimiting:
token:
enabled: true
permitLimit: 30
window: "00:01:00"
queueLimit: 0
authorize:
enabled: true
permitLimit: 60
window: "00:01:00"
queueLimit: 10
internal:
enabled: false
permitLimit: 5
window: "00:01:00"
queueLimit: 0
senderConstraints:
dpop:
enabled: true
allowedAlgorithms: [ "ES256", "ES384" ]
proofLifetime: "00:02:00"
allowedClockSkew: "00:00:30"
replayWindow: "00:05:00"
nonce:
enabled: true
ttl: "00:10:00"
maxIssuancePerMinute: 120
store: "valkey" # legacy "redis" alias accepted; uses redis:// protocol
redisConnectionString: "redis://authority-valkey:6379?ssl=false"
requiredAudiences:
- "signer"
- "attestor"
mtls:
enabled: true
requireChainValidation: true
rotationGrace: "00:15:00"
enforceForAudiences:
- "signer"
allowedSanTypes:
- "dns"
- "uri"
allowedCertificateAuthorities:
- "/etc/ssl/mtls/clients-ca.pem"
clients:
- clientId: scanner-web
grantTypes: [ "client_credentials" ]
audiences: [ "scanner" ]
auth: { type: "private_key_jwt", jwkFile: "/secrets/scanner-web.jwk" }
senderConstraint: "dpop"
scopes: [ "scanner:scan", "scanner:export", "scanner:read" ]
- clientId: signed-sbom-producer
grantTypes: [ "client_credentials" ]
audiences: [ "api://scanner" ]
auth: { type: "client_secret", secretFile: "/secrets/signed-sbom-producer.secret" }
senderConstraint: "dpop"
scopes: [ "scanner.signed-sbom-material.write" ]
- clientId: signer
grantTypes: [ "client_credentials" ]
audiences: [ "signer" ]
auth: { type: "mtls" }
senderConstraint: "mtls"
scopes: [ "signer:sign" ]
- clientId: notify-web-dev
grantTypes: [ "client_credentials" ]
audiences: [ "notify.dev" ]
auth: { type: "client_secret", secretFile: "/secrets/notify-web-dev.secret" }
senderConstraint: "dpop"
scopes: [ "notify.viewer", "notify.operator", "notify.admin" ]
- clientId: notify-web
grantTypes: [ "client_credentials" ]
audiences: [ "notify" ]
auth: { type: "client_secret", secretFile: "/secrets/notify-web.secret" }
senderConstraint: "dpop"
scopes: [ "notify.viewer", "notify.operator" ]
15) Testing matrix
- JWT validation: wrong
aud, expiredexp, skewednbf, stalekid. - DPoP: invalid
htu/htm, replayedjti, staleiat, wrongjkt, nonce dance. - mTLS: wrong client cert, wrong CA, thumbprint mismatch.
- RBAC: scope enforcement per audience; over-privileged client denied.
- Rotation: JWKS rotation while load-testing; zero-downtime verification.
- HA: kill one Authority instance; verify issuance continues; JWKS served by peers.
- Performance: 1k token issuance/sec on 2 cores with Valkey enabled for jti caching.
16) Threat model & mitigations (summary)
| Threat | Vector | Mitigation |
|---|---|---|
| Token theft | Copy of JWT | Short TTL, sender-constraint (DPoP/mTLS); replay blocked by jti cache and nonces |
| Replay across hosts | Reuse DPoP proof | Enforce htu/htm, iat freshness, jti uniqueness; services may require nonce |
| Impersonation | Fake client | mTLS or private_key_jwt with pinned JWK; client registration & rotation |
| Key compromise | Signing key leak | HSM/KMS storage, key rotation, audit; emergency key revoke path; narrow token TTL |
| Cross-tenant abuse | Scope elevation | Enforce aud, stellaops:tenant (+ optional stellaops:project) at issuance and resource servers |
| Downgrade to bearer | Strip DPoP | Resource servers require DPoP/mTLS based on aud; reject bearer without cnf |
17) Deployment & HA
- Stateless microservice, containerized; run ≥ 2 replicas behind LB.
- DB: HA PostgreSQL (schema
authority) for clients/roles/tokens; Valkey for DPoP nonces/jtis and rate-limit buckets. - Secrets: mount client JWKs via K8s Secrets/HashiCorp Vault; signing keys via KMS.
- Backups: DB daily; Valkey not critical (ephemeral).
- Disaster recovery: export/import of client registry; JWKS rehydrate from KMS.
- Compliance: TLS audit; penetration testing for OIDC flows.
18) Implementation notes
- Reference stack: .NET 10 + OpenIddict 6 (or IdentityServer if licensed) with custom DPoP validator and mTLS binding middleware.
- Keep the DPoP/JTI cache pluggable; allow Valkey/Memcached.
- Provide client SDKs for C# and Go: DPoP key mgmt, proof generation, nonce handling, token refresh helper.
19) Quick reference — wire examples
Access token (payload excerpt)
{
"iss": "https://authority.internal",
"sub": "scanner-poe-dev",
"aud": "signer",
"exp": 1760668800,
"iat": 1760668620,
"nbf": 1760668620,
"jti": "9d9c3f01-6e1a-49f1-8f77-9b7e6f7e3c50",
"scope": "signer:sign",
"stellaops:tenant": "tenant-01",
"stellaops:project": "(any)",
"stellaops:idp": "standard",
"cnf": { "jkt": "KcVb2V...base64url..." }
}
DPoP proof header fields (for POST /sign/dsse)
{
"htu": "https://signer.internal/sign/dsse",
"htm": "POST",
"iat": 1760668620,
"jti": "4b1c9b3c-8a95-4c58-8a92-9c6cfb4a6a0b"
}
Signer validates that hash(JWK) in the proof matches cnf.jkt in the token.
20) Rollout plan
- MVP: Client Credentials (private_key_jwt + DPoP), JWKS, short OpToks, per-audience scopes.
- Add: mTLS-bound tokens for Signer/Attestor; device code for CLI; optional introspection.
- Hardening: DPoP nonce support; full audit pipeline; HA tuning.
- UX: Tenant/installation admin UI; role→scope editors; client bootstrap wizards.
21) Identity domain schema ownership
ADR: No-merge decision (Sprint 216, 2026-03-04; retained through the AUTH consolidation)
Authority and IssuerDirectory use separate schemas and separate DbContext classes. This is a deliberate security decision, not a consolidation oversight. The later owner-approved AUTH consolidation folds IssuerDirectory’s physical database and HTTP surface into Authority without merging either schema or DbContext.
21.1 AuthorityDbContext (schema: authority)
The most security-critical schema in the system. Owns:
| Table/Entity group | Security classification | Content |
|---|---|---|
| Users | Critical | Password hashes, MFA state, lockout counters, email verification |
| Sessions | Critical | Active session tokens, refresh tokens, device grants |
| Tokens | Critical | Issued OpTok metadata, revocation records, jti replay cache |
| Roles & Permissions | High | Role-to-scope mappings, audience bindings |
| Clients | High | Client registrations, JWK material references, grant type configs |
| Tenants | High | Tenant/installation registry, cross-tenant isolation boundaries |
| MFA | Critical | TOTP secrets, recovery codes, WebAuthn credentials |
| Audit | High | Authentication event log, admin change trail |
Compiled models: AuthorityDbContext uses EF Core compiled models (generated by Sprint 219). The <Compile Remove> directive for EfCore/CompiledModels/AuthorityDbContextAssemblyAttributes.cs lives in src/Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj.
21.2 IssuerDirectoryDbContext (default schema: issuer)
Manages trusted VEX/CSAF publisher metadata. Owns:
| Table/Entity group | Security classification | Content |
|---|---|---|
| Issuers | Medium | Publisher identity, display name, homepage, tenant scope |
| Issuer Keys | Medium | Public key material (Ed25519, X.509, DSSE), fingerprints, key lifecycle |
| Issuer Audit | Medium | CRUD audit trail for issuer metadata changes |
Compiled models: IssuerDirectoryDbContext also uses EF Core compiled models. The <Compile Remove> directive for EfCore/CompiledModels/IssuerDirectoryDbContextAssemblyAttributes.cs lives in src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.csproj (relocated from src/IssuerDirectory/ by Sprint 216).
Non-testing IssuerDirectory web runtime now requires PostgreSQL persistence; in-memory repositories remain an explicit local/test harness path and cannot be registered through the default infrastructure extension. Anonymous IssuerDirectory mode is likewise a local/test harness path: if IssuerDirectory:Authority:Enabled=false, production-like startup fails before serving unsigned issuer metadata.
IssuerDirectory supports a configured PostgreSQL schema through IssuerDirectory:Persistence:SchemaName and the legacy IssuerDirectory:Postgres:Schema; when unset, the default remains issuer. During the pre-release alpha window, Sprint 20260425-010 accepted rewriting startup migration 001_initial_schema.sql to use unqualified object names under the shared migration runner’s configured search_path. This makes fresh databases converge into the configured schema and keeps repository SQL, startup migrations, and tests aligned. Existing disposable alpha databases that already applied the old issuer.* migration must be reset or manually reconciled because the migration checksum changed.
21.3 No-merge security rationale
Decision: Schemas and DbContexts remain permanently separate. AUTH-9 may move the issuer schema into Authority’s physical database and fold the HTTP surface into the Authority host, but it does not merge the schema or persistence model.
Rationale:
- AuthorityDbContext manages the most security-sensitive data in the system: password hashes, MFA state, session tokens, refresh tokens, and tenant isolation boundaries.
- A merged DbContext would mean any code path with access to issuer metadata could also reach authentication internals via the same EF Core connection and change tracker.
- The security principle of least privilege demands keeping these schemas separate even though they share the same PostgreSQL instance.
- Blast radius containment: a vulnerability in issuer metadata handling (e.g., a malformed CSAF publisher import) cannot escalate to credential compromise when the schemas are isolated.
- Each DbContext has its own migration history, compiled models, and connection pooling, enabling independent security hardening.
21.4 IssuerDirectory domain ownership
As of Sprint 216, the IssuerDirectory source tree is owned by the Authority domain:
- Source:
src/Authority/StellaOps.IssuerDirectory/(service projects) - Persistence:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/ - Tests:
src/Authority/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/ - Client library:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Client/(shared with Excititor, DeltaVerdict) - Solution: included in
src/Authority/StellaOps.Authority.sln - Runtime transition: the AUTH-9 fold completed on 2026-09-08 with Authority serving the issuer surface and the predecessor container stopped; the window’s issuer/tenant forcing functions and 120-iteration soak passed. Authority’s
024_authority_issuer_directory_tables.sqlcreates the separateissuerschema through its existingauthority.schema_migrationsledger, andAddFoldedIssuerDirectoryregisters repositories without a second migration host. CM-2 (2026-09-09) removes Platform’s remaining IssuerDirectory migration plugin and implementation reference, closing the local CLI’s predecessor migration authority. AUTH-10 still owns retirement of the standalone host’s source. The fold remains an explicit source/configuration switch; this source increment performs no live action. The API paths, scopes, client contract, and separateissuerschema are preserved.
