Authority — Architecture
Verified against commit
942a18727c(2026-09-26, DOC-2 of SPRINT_20260926_001). Re-verify by reading the evidence paths listed in the Evidence section.
PCD-39 issuer-fold deployment update: verified against source commit
3372e963a8and the 2026-09-30 isolated-run receipt linked in §21. Re-verify withpython devops/release/bundle/tools/test_bundle_issuer_directory_fold.pyandbash tools/scripts/validate/check-bundle-generated.sh.
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
0) Mission & boundaries
Credential-state retention and its verification are described in retention-classification.md. The keeper preserves unknown-expiry records, active credentials and security evidence; it does not infer expiry from a current client lifetime or rewrite issuer signing history. The separate login-attempt keeper implements Q-50 B: seven days by default for a strict allowlist of routine successful authentication events, and an explicitly configured audit period (365 days by default under the 2026-10-13 ruling) for failed, administrative, security-relevant or unknown events. Platform owns the audited versioned period pair in its installation settings. Authority obtains one authenticated snapshot per sweep; host transport wiring changes require restart, while edited periods apply at the next sweep. Unavailable or invalid policy reads pause disposal. The keeper is enabled by default and requires its exact valid retention index before deleting bounded batches. See the classification and settings.
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 Stella Ops services, the Console and the CLI.
Boundaries.
- Authority does not validate entitlements/licensing. That’s enforced by Signer using PoE with the cloud Licensing Service.
- Authority tokens are operational only (source default access-token lifetime 2 minutes,
Authority:AccessTokenLifetime; the release bundle sets 30 minutes) 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). - Folded issuer directory — the IssuerDirectory HTTP surface (
src/Authority/__Libraries/StellaOps.IssuerDirectory.Api) is mapped by the Authority host whenIssuerDirectory:Fold:Enabledis true; the standalone IssuerDirectory host is retired (§ 21). - Doctor surface —
MapServiceDoctorEndpoints()under theauthority.ops.healthpolicy (ops.healthscope).
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 A request withoutscopereceives the client’s ordinary allowed scopes, omitting operation-bound policy publication/promotion, incident activation, pack/exception approval and release gate bypass. Explicit requests retain their metadata and approval checks; see the CLI scope census. - 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.Standard-plugin bootstrap (
devops/etc/authority/plugins/standard.yaml):Initial default-admin provisioning fails startup when the private password is missing or violates the configured password policy. An existing populated credential is preserved across subsequent bootstrap passes. Optional
bootstrapUserrole grants follow successful credential provisioning; a refused registration grants no new privileges. Standard-provider password grants with an absent or empty stored hash use the normalinvalid_grantrefusal, failed-attempt counters, lockout and audit path. The provider still verifies populated hashes through its configured crypto registry. See bootstrap modes.defaultAdminBootstrapcreates or repairs theadminuser (roleadmin) from the password inSTELLAOPS_ADMIN_PASS; the release bundle requires that variable.EnsureAdminRoleAsyncthen creates any missing permission rows forStellaOpsScopes.All, theadminrole and the assignment.generic tenant anchor:
tenantId: "default". It is the tenant of the bootstrap administrator and the default tenant of bootstrap clients; users of other tenants sign in by naming their tenant (§ 3.2b).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 claim.stella-ops-uiredirect and post-logout URIs are install-specific: the seed writes them against the reserved originhttps://public-origin.invalidand declarespublicOriginsEnvironmentVariable: "STELLAOPS_CONSOLE_PUBLIC_ORIGINS". The Standard plugin expands every such URI once per origin in that variable (space-separatedscheme://host[:port], a scheme-default port dropped the way a browser spells its origin) and fails startup closed when the variable is unset, empty, or holds a non-origin (StandardPluginOptions). The lab compose supplieshttps://stella-ops.local https://127.1.0.1; the release bundle rendershttps://${STELLA_PUBLIC_HOST:-$STELLA_BIND_ADDRESS}:${STELLA_HTTPS_PORT}plus optionalSTELLA_CONSOLE_ADDITIONAL_ORIGINS.StellaOpsUiConsoleClientConformanceTestspins every console scope request source (Platformenvsettingsin lab and bundle compose, rollback overlays) as a subset of the client’s allow-list, including the absence of retiredremediation:*scopes.The gateway validates
aud=stellaops; a UI client missingstellaopsmakes every browser token fail audience validation (IDX10214), leaving the gateway principal anonymous. Startup migration007_authority_first_party_audience_convergence.sqlheals such volumes (§ 4.1).stellaops-clicarries only the scopes the shipped CLI can exercise (ceiling pinned byStellaOpsCliClientScopeCeilingTests; migrationS058_stellaops_cli_scope_ceiling.sqland its successors through the currentS066_stellaops_cli_scope_ceiling_policy_operate.sqlintersect the persisted row with the same ceiling because the bootstrapper unions and never shrinks). These are Seed-category migrations, so a scope a successor drops also ships a Startup-category companion:031_authority_stellaops_cli_analytics_read_retirement.sqlremovesanalytics.read, which left the ceiling on 2026-10-01 with the withdrawn CLIanalyticscommands, and032removes the retired JobEngine reads. Startup034and its successor035_authority_stellaops_cli_scope_ceiling_policy_operate.sqlapply the complete ceiling with bootstrap disabled.policy:operateentered the ceiling and the standing grant on 2026-10-09 for the CLIbudget consumecommand (owner ruling, census section WSF-2). Operator procedures mint through the purpose-scoped password-grant clientsstellaops-estate-admin-operator,stellaops-onboarding-operator,stellaops-compliance-operator,stellaops-release-ops-operatorandstellaops-break-glass-operator(census:operations/stellaops-cli-scope-census.md).stellaops-break-glass-operatorholds onlyopenid profileat rest and is the only client whose audiences includestellaops-gateway-introspection;stellaops-gateway-introspectionis the confidential client the router gateway uses for break-glass per-call introspection (§ 3.3).
The table below is generated from
devops/etc/authority/plugins/standard.yamlat the verified commit; the file is authoritative. Clients with more than eight scopes are marked “broad scope list (see file)”.Client Grant types Audiences Tenancy Notes stella-ops-uiauthorization_coderefresh_tokenstellaopsapi://export-centerapi://release-orchestratorapi://issuer-directorynotifynone declared public; redirect URIs expanded from STELLAOPS_CONSOLE_PUBLIC_ORIGINS; broad scope list (see file)stellaops-cliauthorization_codepasswordrefresh_tokenstellaopsapi://issuer-directorysignerregistrytenants: default tenant-golden-localpublic; firstParty: true; broad scope list (see file)stellaops-cli-automationclient_credentialsstellaopsapi://export-centerapi://integrationsapi://reachgraphapi://release-orchestratortenants: default e2e-labconfidential; firstParty: true; broad scope list (see file)stellaops-scanner-workerclient_credentialsstellaopsapi://integrationsapi://reachgraphapi://release-orchestratortenants: default e2e-labconfidential; firstParty: true; scopes:advisory:ingestadvisory:readaoc:verifyintegration:operateintegration:readrelease:readrelease:writescanner:scansignals:write(PCD-187: thepublish-signals-unknownsstage; requested together withaoc:verify)stellaops-integrations-webclient_credentialsstellaopsapi://scannerapi://policy-enginetenants: defaultconfidential; firstParty: true; scopes:scanner:projection:readplatform:doctor:registerpolicy:readstellaops-timeline-webclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:findings:projection:readplatform:doctor:registerstellaops-scanner-webclient_credentialsstellaopsapi://policy-enginetenants: defaultconfidential; firstParty: true; scopes:advisory:readaoc:verifypolicy:preview:invokeattest:createstellaops-offlinekit-activatorclient_credentialsstellaopsapi://scannerapi://vulnerabilitiestenants: defaultconfidential; firstParty: true; scopes:scanner:offlinekit:artifact-bomsadvisory:ingestvex:ingeststellaops-concelier-serviceclient_credentialsstellaopstenants: default e2e-labconfidential; scopes: concelier.jobs.triggerconcelier.mergenotify.operatorstellaops-jobengine-anchorclient_credentialsstellaopstenants: default e2e-labconfidential; firstParty: true; scopes:attest:createstellaops-scheduler-concelier-observationsclient_credentialsstellaopstenants: default e2e-labconfidential; firstParty: true; scopes:ops.health(jobengine-web’s shared token client;vuln:viewleft with the retired Concelier resolver)stellaops-jobengine-vulnerability-hubclient_credentialsstellaopstenants: default e2e-labconfidential; firstParty: true; scopes:advisory:readaoc:verify(the scheduler’s by-CVE resolution and advisory trigger)stellaops-unknowns-vulnerability-hubclient_credentialsstellaopstenants: default e2e-labconfidential; firstParty: true; scopes:advisory:readaoc:verify(unknowns-web’s rescan re-resolution of due unknowns)stellaops-jobengine-scanner-projectionclient_credentialsstellaopsapi://scannertenants: default e2e-labconfidential; firstParty: true; scopes:scanner:projection:readstellaops-unknowns-scanner-projectionclient_credentialsstellaopsapi://scannertenants: default e2e-labconfidential; firstParty: true; scopes:scanner:projection:read(unknowns-web’s producer ofunknowns.unknown; the fourth named carrier)stellaops-scheduler-scanner-runnerclient_credentialsstellaopsapi://scannertenants: default e2e-labconfidential; firstParty: true; scopes:scanner:readscanner:scanstellaops-vulnerabilities-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:platform:doctor:registercatalog:replicatestellaops-platform-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:catalog:replicateops.healthplatform:doctor:registerstellaops-jobengine-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:catalog:replicateplatform:doctor:registerstellaops-findings-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:platform:doctor:registerattest:createstellaops-findings-scanner-projectionclient_credentialsstellaopsapi://scannertenants: default e2e-labconfidential; firstParty: true; scopes:scanner:projection:readgraph-api-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:platform:doctor:registerstellaops-evidence-webclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:catalog:replicateplatform:doctor:registerpolicy-engineclient_credentialsstellaopsapi://scannerunknownstenants: default e2e-labconfidential; firstParty: true; scopes:advisory:readaoc:verifycatalog:replicatecrypto:readfindings:projection:readissuer-directory:readrelease:readscanner:readunknowns:readstellaops-notifyclient_credentialsnotifystellaopsnone declared confidential; scopes: notify.viewernotify.operatornotify.adminnotify.escalatecatalog:replicateplatform:doctor:registerstellaops-exportcenter-nis2client_credentialsstellaopsapi://timelinetenants: default e2e-labconfidential; firstParty: true; scopes:authority:tenants.readsbom:readtimeline:readstellaops-plugin-publisherclient_credentialsstellaopsregistrytenants: defaultconfidential; firstParty: true;plan: plugin-publisher; scopes:registry.token.issuestellaops-release-dispatchclient_credentialsstellaopsapi://release-orchestratortenants: default e2e-labconfidential; firstParty: true; broad scope list (see file), includingplatform:doctor:registerfor the release-orchestrator Doctor registration (Startup040_authority_release_dispatch_doctor_register_scope.sql, SeedS068_release_dispatch_doctor_register_scope.sql)stellaops-excititor-webclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:attest:createstellaops-findings-security-internalclient_credentialsapi://policy-engineapi://sbomservicestellaopstenants: default e2e-labconfidential; firstParty: true; scopes:advisory:readaoc:verifypolicy:readsbom:operatesbom:readstellaops-findings-nis2-ledgerclient_credentialsnotifytenants: default e2e-labconfidential; firstParty: true; scopes:notify.operatorstellaops-advisory-ai-internalclient_credentialsstellaopsapi://graphapi://policy-engineapi://scanneradvisory-aitenants: defaultconfidential; scopes: advisory:readaoc:verifyfindings:readgraph:readpolicy:readvex:readplatform:doctor:registerstellaops-advisory-ai-operatorpasswordstellaopstenantId: default,tenants: defaultpublic; firstParty: true; scopes:openidprofileaoc:verifyadvisory:readadvisory-ai:viewadvisory-ai:operateops-memory:readops-memory:writestellaops-estate-admin-operatorpasswordstellaopstenantId: default,tenants: defaultpublic; firstParty: true; broad scope list (see file)stellaops-onboarding-operatorpasswordstellaopstenantId: default,tenants: defaultpublic; firstParty: true; broad scope list (see file)stellaops-compliance-operatorpasswordstellaopstenantId: default,tenants: defaultpublic; firstParty: true; broad scope list (see file)stellaops-release-ops-operatorpasswordstellaopstenantId: default,tenants: defaultpublic; firstParty: true; broad scope list (see file)stellaops-break-glass-operatorpasswordstellaopsstellaops-gateway-introspectiontenantId: default,tenants: defaultpublic; firstParty: true; scopes:openidprofileconsole-workspaceclient_credentialsapi://findings-ledgerstellaopsapi://schedulertenants: default e2e-labconfidential; firstParty: true; scopes:vuln:viewscheduler:readpolicy:runaoc:verifyplatform:doctor:registercatalog:replicatefindings-ledger-devclient_credentialsapi://findings-ledgernone declared confidential; scopes: findings:readscanner-poe-devclient_credentialssignernone declared confidential; senderConstraint: dpop; scopes:signer:signstellaops-offlinekit-builderclient_credentialsstellaopssignertenants: defaultconfidential; firstParty: true;senderConstraint: dpop; scopes:signer:readsigner:signstellaops-gateway-introspectionclient_credentialsapi://gateway-introspectiontenants: defaultconfidential; firstParty: true; scopes:ops.healthstellaops-signals-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:platform:doctor:registercatalog:replicatestellaops-offlinekit-doctorclient_credentialsstellaopstenants: defaultconfidential; firstParty: true; scopes:platform:doctor:registercatalog:replicate
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.gitignoreaccidentally 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 siblingdevops/etc/authority.plugins/standard.yaml(note the dot) is not read by compose: it is the default-path partial copy that carries the SQL-superseded service clients and the least-privilege service identities whose descriptor pin tests (StandardPluginOptionsTests) assert both files.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 are mounted from the untracked packaged-output directory.runtime/plugins/authority/<profile>/(docker-compose.plugins.recommended.yml,docker-compose.plugins.harness.yml). 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) |
installation | Scope key used by Platform’s setup sessions (PlatformSetupService.InstallationScopeKey). Not FK-referenced by Authority; seeded so the key exists in the tenant catalogue. | 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 (database rules,.memory/standards/DATABASE_RULES.md). The composepostgres-init/scripts carry no Authority schema or seed. - 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. Which account the Standard provider verifies is decided before this binding: it looks the username up first in the requested tenant, then in its configured tenant (§ 3.2b).
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 algorithm:
Authority:Signing:Algorithm, default ES256 (AuthoritySigningOptions); other algorithms depend on the configured crypto provider. Rotation is supported via kid in JWKS.Token format: JWT access tokens (JWS, access-token encryption disabled). No reference-token mode is configured.
Clock skew tolerance: resource servers default to 60 s (
StellaOpsResourceServerOptions.TokenClockSkew, at most 5 minutes); DPoP proofs allow 30 s (AuthorityDpopOptions.AllowedClockSkew).
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 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.Console admin tenant boundary (PCD-867). A
role/console-adminholder without the global-admin entitlement administers only the tenant in itsstellaops:tenantclaim; the header, query and body never widen it. It cannot create tenants, read or change another tenant’s catalog entry or operator-compliance settings, suspend or resume another tenant, assign a client to another tenant, change or rotate a client that serves another tenant, grant a client a scope its own token lacks, or setisGlobalAdmin. Refusals are HTTP 403cross_tenant_admin_forbidden,global_admin_requiredorclient_scope_not_held; another tenant’s client answers HTTP 404client_not_found, like a missing one, and never yields a rotated secret. A global administrator (the Standard-plugin bootstrap administrator, or a user a global administrator promoted) keeps installation-wide administration and is not subject to the scope cap. Details: Console admin RBAC, section 3.4.
2.1 Access token (OpTok) — short-lived
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> (iat + Authority:AccessTokenLifetime; source default 2 minutes)
iat = <unix ts>
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
stellaops:admin = true // global administrator (optional)
acr / amr = assurance class / methods // password and authorization-code flows
sid = <session id> // interactive sessions
There is no inst/installation claim. Human issuance includes verified-user role claims and resolves the compiled grants into the scope claim. 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, endpoint mapped withapp.MapGet("/jwks", …)inProgram.cs) 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,/connect/revokeand/connect/revocation. 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://public-origin.invalid/"and expanded once per origin inSTELLAOPS_CONSOLE_PUBLIC_ORIGINS(§ 1). 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
The legacy
/oauth/token,/oauth/introspectand/oauth/revokealiases and theirAuthority:ApiLifecycle:LegacyAuthconfiguration were removed on 2026-09-27 (PCD-78). Clients call/token,/introspectand/revoke. Seedocs/api/authority-legacy-auth-endpoints.md.
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).
- client secret: secret-based client auth, optionally with a DPoP header (
senderConstraint: dpopon the client).private_key_jwtclient authentication is not verified in source.
- 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 (pruned evidencedocs/implplan/_evidence/20260831-auth16-console-login.mdat5a20d13f8a). 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. The requested tenant also selects which tenant’s account is verified, and the login form carries it to the sign-in request (§ 3.2b). 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.
StellaOpsUiConsoleClientConformanceTests checks every shipped Console scope source for these scopes, for repeated scopes and for empty entries: the Platform environment in the lab and bundle compose and the gateway-mounted envsettings-override.json in both trees. The browser reads its settings from Platform’s /platform/envsettings.json; no gateway route serves the mounted envsettings-override.json, which is kept consistent with it.
The stella-ops-ui client allows none of these operation-bound scopes (PCD-20, 2026-09-26). The bootstrap reseed unions the seed with the persisted row and never shrinks it, and the S001 baseline inserted the row with policy:publish, so S062_stella_ops_ui_operation_bound_scope_retirement.sql removes the scope from the persisted row, both the allowed_scopes column and properties.allowedScopes. User role grants are unchanged. The AUTH-16 login-rollback compose overlay requests policy:publish and needs the client grant restored by hand before it can authorize. 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.2b User sign-in account lookup
Local users live in authority.users, where a username is unique only within a tenant (UNIQUE (tenant_id, username)). The Console admin API creates a user in the tenant of the request, so one installation can hold users of many tenants and the same username in several of them. The Standard provider’s tenantId (default in the shipped standard.yaml) is the tenant of its bootstrap administrator and clients, not a limit on which users can sign in.
The Standard provider resolves the account to verify in this order (PCD-875):
- the tenant the caller names: the
tenantparameter of the password grant or of/authorize, or thestellaops:tenantclaim of the authorization code or refresh token being redeemed; - the provider’s configured
tenantId; - the
seedAdminRecovery.fallbackTenantIds, only when seed-admin recovery is enabled.
The first tenant that holds the username supplies the account. The password is checked against that account only, and only that account’s failed-attempt counter and lockout change. A wrong password sent for one tenant never reaches a same-named account in another tenant.
The lookup does not grant a tenant. After verification the token is bound as described in § 1.2 (password grant) and § 3.2a (interactive): to the verified account’s home tenant; a named tenant other than the home tenant is refused with invalid_request unless the account is a global administrator, and the client’s tenant assignments and a suspended tenant still refuse the grant. Consequences:
- A user homed outside the configured tenant must name its tenant. Without it the provider looks only in the configured tenant and answers
invalid_grant. - A global administrator homed in the configured tenant who names another tenant is found in its home tenant when the named tenant has no account with that username. When the named tenant has its own account with the same username, that account is the one checked.
- The interactive login form carries the
tenantparameter of the rendering request to the sign-in request as a hidden field. - Clients must be assigned the tenant. Creating a tenant through
POST /console/admin/tenantsassigns it to the first-party clients (§ 10.1).
Re-verify (real Authority host on a disposable PostgreSQL fixture, plus the provider unit tests):
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*StandardProviderTenantSignInTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj -Class "*StandardUserCredentialStoreTests" -BuildProjectReferences
3.2c Disabled accounts
POST /console/admin/users/{userId}/disable sets authority.users.enabled = false; /enable sets it back and clears the failed-attempt counter and lockout. A disabled account cannot obtain or renew a token on any user path (PCD-884):
| Path | Where it is refused | Caller sees |
|---|---|---|
Password grant (POST /token, grant_type=password) | Standard provider VerifyPasswordAsync, after the password check | invalid_grant, the same description as a wrong password |
Interactive sign-in (POST /authorize, /connect/authorize) | the same provider check | the login form with “Invalid username or password.”, no code |
Authorization-code redemption (grant_type=authorization_code) | ValidateAccessTokenHandler, before the code is redeemed | invalid_grant, “The grant is no longer valid.” |
Refresh (grant_type=refresh_token) | ValidateAccessTokenHandler, before the token is redeemed | invalid_grant, “The grant is no longer valid.” |
Introspection (POST /introspect) of the account’s tokens | ValidateAccessTokenHandler | active: false |
Details:
- The provider checks the account state only after the password verifies. A wrong password against a disabled account is answered and counted like any wrong password; a correct one gets the wrong-password answer without a successful-login stamp, a counter change or a hash upgrade. The answer therefore reveals nothing about the account (lockout, checked first, gets the same answer; see § 3.2d).
- The code and refresh checks read the account through the provider’s subject lookup, whose
AuthorityUserDescriptor.Enabledcarriesauthority.users.enabled. A code or refresh token issued before the disable stops working at its next use. It is not redeemed by the refusal, so it works again if the account is re-enabled before it expires. - The host also refuses a password verification that a provider reports successful for a descriptor with
Enabled = false(TenantScopedCredentialLookup), as a second layer. - Audit: the provider writes
authority.plugin.standard.password_verificationwith reasonAccount is disabled.andplugin.failure_code=AccountDisabled; the password grant writesauthority.password.grantwithfailure.code=AccountDisabled; a refused code or refresh redemption writesauthority.token.subject.disabled(reasonAccount is disabled.,grant.type). Introspection refusals are logged, not audited. - Not covered: access tokens already issued stay valid at resource servers that validate the JWT locally until they expire (
AccessTokenLifetime, source default 2 minutes). Disabling an account does not revoke its tokens. - External providers (LDAP, SAML, OIDC) have no Authority-side account state; their directory or identity provider decides at sign-in.
Re-verify (real Authority host on a disposable PostgreSQL fixture, plus the provider and host unit tests):
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*DisabledUserSignInTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj -Class "*StandardUserCredentialStoreTests" -BuildProjectReferences
3.2d Sign-in refusals do not reveal accounts
An unknown username, a wrong password, a disabled account and a locked account get the same answer on each user sign-in path (PCD-889):
| Path | Caller sees in all four cases |
|---|---|
Password grant (POST /token, grant_type=password) | HTTP 400, error=invalid_grant, error_description="Invalid credentials."; the same response body |
Interactive sign-in (POST /authorize, /connect/authorize) | HTTP 200, the login form with “Invalid username or password.” and the typed username; no code |
Details:
The password grant passes a provider’s message to the caller only for states that a provider reports after the password verified (
PasswordExpired,RequiresPasswordReset,RequiresMfa) and for a provider fault (UnknownError). Every other failure code, a missing code and a code without a message get “Invalid credentials.” (HandlePasswordGrantHandler.ResolveCredentialRefusalDescription). The interactive sign-in shows one fixed message for every failure.A locked account still refuses every password, the correct one included, until its lockout ends, but the caller cannot tell it from a wrong password. The refusal carries no
Retry-Afterheader and no 423 status; the lockout is visible in the audit.Equal work: the Standard provider verifies one password hash on every refusal. For an unknown username, a locked account and an account without a stored hash it verifies the supplied password against a dummy hash and discards the result. The dummy hash is created once per process and hashing configuration, with the configured algorithm and cost.
The paths still differ in database work: a wrong password records the failed attempt and reads the account again, an unknown username is looked up in each candidate tenant (§ 3.2b). This is small against the hash cost.
The audit records the reason the caller is not told:
Case authority.plugin.standard.password_verificationauthority.password.grant(password grant only)Unknown username outcome failure, reasonUnknown username., no subjectfailure.code=InvalidCredentialsWrong password failure,Invalid credentials.,plugin.failed_attemptsfailure.code=InvalidCredentialsDisabled account, correct password failure,Account is disabled.failure.code=AccountDisabledLocked account, or the attempt that locks it locked_out,Account is temporarily locked.,plugin.lockout_untiloutcome locked_out,failure.code=LockedOutThe LDAP provider answers an unknown user and a wrong password “Invalid credentials.” and records the reason in the
ldap.failureaudit property (user_not_found,invalid_credentials). The dummy hash applies to the Standard provider only; LDAP verifies a password with a directory bind, which it skips for an unknown user.
Re-verify (real Authority host on a disposable PostgreSQL fixture, plus the host and provider unit tests):
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*AccountEnumerationSignInTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class "*PasswordGrantHandlersTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj -Class "*StandardUserCredentialStoreTests" -BuildProjectReferences
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.
Consumer: router-gateway break-glass introspection (owner ruling Q-33, 2026-09-24). The gateway introspects each request whose token carries findings:recovery before forwarding it, as the confidential client stellaops-gateway-introspection. It never uses the estate-wide stellaops credential. The gateway relies on four behaviours of this endpoint:
- A revoked, expired or unknown token answers
200 {"active": false}. The gateway refuses the request401. - A wrong or unknown client, or a wrong secret, answers
401. The gateway’s Doctor checkdoctor.router.break-glass.introspection-identityreads that as a rejected credential. - An authenticated caller that sends a string which is not a token gets
200 {"active": false}. The Doctor check uses this to prove the credential works without minting a token. - A caller that is not among the token’s audiences also gets
{"active": false}. The gateway therefore checks the audience itself before it calls, so a configuration fault is not recorded as a revocation. Onlystellaops-break-glass-operatornames the introspection client in itsallowedAudiences.
A change to any of these four answers changes the gateway’s break-glass verdicts. Gateway contract and refusal codes: docs/modules/router/architecture.md, section “Break-glass per-call introspection”.
Requests targeting the legacy
/oauth/{introspect|revoke}paths receive deprecation headers (see § 3.2).
- 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 signed with the active 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.
Operator-created clients (PCD-273). POST /console/admin/clients accepts an optional audiences string array. PATCH /console/admin/clients/{clientId} replaces that array when supplied; omitted/null preserves it, and [] clears it. Client create/update responses and client-list entries return the persisted audiences. Values are trimmed, deduplicated and sorted; empty/null entries, embedded whitespace and control characters fail with HTTP 400 invalid_audiences before persistence. The existing ui.admin, authority:clients.write, tenant and fresh-authentication requirements apply. Audience changes are included in the existing client audit events.
For example, a freshly authenticated operator can register a resource reader:
{
"clientId": "resource-reader",
"tenant": "default",
"grantTypes": ["client_credentials"],
"scopes": ["aoc:verify", "signals:read"],
"audiences": ["stellaops"],
"requireClientSecret": true,
"clientSecret": "<runtime-supplied secret>"
}
Newly minted access tokens project the stored values as aud; existing tokens retain their issued claims until expiry or revocation. A scope grant alone does not satisfy a resource’s audience requirement. Provision through the API and mint a fresh token after a change. No database edit is part of this workflow. Verified against source ecdfa783bb (2026-10-04): the real PostgreSQL/Kestrel regression mints both operator and client tokens through /connect/token, calls the actual protected client-list resource with its configured stellaops audience, and refuses missing/wrong audiences. Re-verify with the canonical targeted wrapper for the complete Authority test project, or -Class "*ConsoleClientAudienceProvisioningTests" -BuildProjectReferences for that boundary. This local loopback proof does not claim a shared-estate deployment.
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 reconciles the clients listed in standard.yaml bootstrapClients (including stella-ops-ui) additively; it unions and never removes. 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(RegisterScopesinProgram.cs), so those four standard scopes plus everyStellaOpsScopesconst are the strings that may legitimately appear in ascopeclaim (the developer/viewer allow-lists inAuthorityHumanScopeResolver.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
authority.*.manageconsts.StellaOpsScopesstill definesauthority.users.manageandauthority.clients.manage. 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 and issued scopes (CCP-080, source only). Interactive authorization-code login and password issuance both use AuthorityHumanScopeResolver. It intersects requested scopes, the actual client ceiling, and the deterministic union of recognized compiled roles. Missing or unknown roles contribute no product scopes; only requested, client-permitted OIDC identity/session scopes remain. An empty client ceiling grants nothing. Existing administrator families keep their client/request-bounded grants. The four professional bundles, developer/viewer families and four published Scanner bundles retain their existing scope sets.
role/release-approver adds only release:publish; role/decision-signing-enrollee adds only authority:signing-keys.enroll. Ordinary interactive login excludes release:gate-bypass, including for administrators. Password login without explicit scopes preserves its existing operation-scope exclusions before applying the human boundary. Explicit password operation requests retain the existing validators; the separate non-admin operation-bound gate-bypass flow remains outside this change.
The persisted authority.roles/role_permissions catalog remains issuance-inert. Custom role editing does not manufacture token grants. See human scope issuance for the exact compiled roles, tests, future acceptance assignments and deployment boundary. Password audit records distinguish validation success from the final issued grant; the latter records the narrowed scopes after credential verification.
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, — BAZ-3 — attestor evidence-transparency / proof-chain reads through attest:read, and — CCP-057 — approval-policy reads through release.policy.read). It does not receive replay:write, vuln:operate, the attestation-authoring attest:create, or release.policy.manage. 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. Both human issuance paths strip requested scopes outside each role’s compiled bundle. The steward retains release writes, orchestration operation and Scheduler operation; the contributor retains release writes and Scheduler reads. Neither base bundle grants release publication, administration or exception approval.
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.
The Console requests the approval-policy scopes for the approval-policy editor (/releases/policies, Release Orchestrator ApprovalPolicyEndpoints) and the federated-telemetry scopes for its federation pages (Platform FederationTelemetryEndpoints). The stella-ops-ui client allows all six (devops/etc/authority/plugins/standard.yaml; startup migration 036_authority_stella_ops_ui_console_editor_scopes.sql converges persisted rows). Issuance gives each interactive role only the scopes its bundle names:
| Scope claim | Professional roles that receive it | Reason |
|---|---|---|
release.policy.read | Executive Assurance Viewer, Release Operations Steward | The steward operates promotions and inspects approvals; the assurance viewer reviews the approval policy a recorded decision cites. Both see the approval-policy editor read-only. |
release.policy.manage | None | A holder can set an approval policy to auto-approve and so remove the human approval that release:publish guards; it is granted separately, like release:publish. |
telemetry:participation:read, telemetry:participation:write | None | Current-tenant telemetry consent is a tenant administration decision. |
platform:federation:read, platform:federation:write | None | Installation federation status, bundles, intelligence, privacy budget and trigger are installation operations. |
Administrator-family roles receive requested, client-permitted scopes, except release:gate-bypass during ordinary interactive login, so an administrator session can carry all six editor scopes. A principal with no recognized role, including one holding only an unrecognized operator or auditor label, receives none of these product scopes. Because a requested scope outside the client allow list makes the whole authorize request fail with invalid_scope, the allow list (startup migration 036, the shipped standard.yaml) must be live before Platform requests these scopes (see the Platform architecture, Authorization scopes). ConsoleAdminRoleCatalogTests, AuthorizeEndpointLoginTemplateTests and StellaOpsUiConsoleClientConformanceTests pin the grants, the issuance and the client allow list.
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.
| 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. | StandardPluginBootstrapper.EnsureScopeCatalogAsync, the catalog’s single writer since SPRINT_20260824_010 P8-18, runs on every boot for the plugin’s tenant: it adds a row for each StellaOpsScopes.All entry that has none and removes the rows named in RetiredScopes (src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/RetiredScopes.cs). No new migration may write the catalog (AuthorityScopeSeedMigrationConformanceTests); S001_v1_authority_operational_baseline.sql and the other catalog-writing files that test lists are applied history. |
Human token issuance paths (AuthorizeEndpoint.TryAuthenticateAndSignIn and HandlePasswordGrantHandler.HandleAsync) resolve through AuthorityHumanScopeResolver.Resolve(requestedScopes, clientAllowedScopes, principal). The compiled role union and the two scope ceilings bound the final principal. The authority.permissions table is never joined by these paths. Resource-server authorization operates on the resulting JWT scope claim. Client-credentials issuance keeps its separate machine-identity contract.
Console role editor path (ConsoleAdminEndpointExtensions.cs):
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. The collapsed S001 baseline carries the historical full-scope seed; later scopes came from S0xx top-ups until P8-18 and now come from the boot reconciliation. Without it, the Console role editor would offer only names that match no 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.
Seeded developer credential scrub. The folded S022_developer_self_service_abac_seed section of S001 gave tenant default’s developer row a fixed development password hash, role/service-release-contributor and demo ABAC attributes. Startup 039_authority_developer_seed_credential_scrub.sql and its Seed companion S067_developer_seed_credential_scrub.sql (same statement; S067 runs after a pending baseline replay re-seeds the row) clear that hash, salt and password-change stamps only while the hash still equals the seed literal. When the roles and attributes are also still exactly the seed values, they remove both metadata keys and set enabled = false, the column the admin disable API writes. An operator-rotated hash leaves the row unchanged; no row is deleted, and admin, other users and other tenants are untouched. Both files are declared default-tenant migrations in AuthorityMigrationSqlExecutor (§ 1.1a). Contract: operations/bootstrap-modes.md (Bootstrap Account Contract). Re-verify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/__Tests/StellaOps.Authority.Persistence.Tests/StellaOps.Authority.Persistence.Tests.csproj -Class "*MigrationS067DeveloperSeedCredentialScrubTests" -BuildProjectReferences and -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj -Class "*DeveloperSeedCredentialScrubPasswordGrantTests".
Release-dispatch Doctor registration grant. Release Orchestrator registers its Doctor check catalog with Platform under the shared stellaops-release-dispatch identity, and Platform’s capability endpoints require platform:doctor:register (owner ruling 2026-10-19, SPRINT_20261007_028 CCP-071). Startup 040_authority_release_dispatch_doctor_register_scope.sql and its Seed companion S068_release_dispatch_doctor_register_scope.sql (same statement; S068 runs after S001 inserts the client on a fresh install with Seed migrations enabled) append that scope to the client’s allowed_scopes column and set the properties.allowedScopes value the client-credentials handler reads to the union of its previous value, the column and the scope. Neither file removes a scope from either value, and an absent client is a no-op. standard.yaml declares the same scope, so the provisioning union converges it too. Re-verify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/__Tests/StellaOps.Authority.Persistence.Tests/StellaOps.Authority.Persistence.Tests.csproj -Class "*MigrationS068ReleaseDispatchDoctorRegisterScopeTests" -BuildProjectReferences.
Withdrawn scopes. Deleting a const from StellaOpsScopes stops the reconciler adding its row, but every estate already holds one (the reconciler created it while the const existed, and the applied S001 seed inserted most of them). A withdrawn scope is therefore listed in RetiredScopes in the change that deletes its const; on the next boot the reconciler deletes that row for the plugin’s tenant, and the authority.role_permissions grants cascade with it. A name in the register must not also be declared (StandardPluginBootstrapperTests.Retired_scopes_are_not_declared_in_the_scope_catalog). Current register: risk-engine:read and risk-engine:operate, withdrawn with the RiskEngine host (deleted 2026-08-27; SPRINT_20260930_006 PCD-271). Token issuance is unaffected: it reads authority.clients.allowed_scopes, and no shipped client grants either scope.
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 /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— originalAuthorityAuditSinkauthentication and security events, including successful administrative operations; classified retention follows Q-50 B, not a guessed success flag.bootstrap_invites— first-run invite tokensverdict_manifests— VEX verdict manifests for deterministic replayclient_tenants— typed client-to-tenant link table (§ 10.2)identity_provider_configs(019) andbreak_glass_events(025)- schema
issuer— created by024_authority_issuer_directory_tables.sqlfor the folded issuer directory (§ 21) - 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, folded in from the pre-1.0002_drop_deprecated_audit_tables.sql). Timeline serves unified audit reads;AuthorityAuditSinkstill writes original events tologin_attemptsbefore optional Timeline emission.
ADR-039 P13 retention classification. Every authority.* table declared in the source lineage (001, 019, 025) carries a retention-class: header; the three deprecated local-audit tables are declared and dropped inside 001. 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. Migration 030_authority_retention_classes.sql supplies the later reviewed classifications, and 037_authority_login_attempt_retention.sql restates the login-attempt disposal boundary without editing either applied declaration.
The classified dispositions are:
P13 source-of-truth: tenant/identity/RBAC, clients and service accounts, API keys, bootstrap invites, revocations/export cursor, break-glass events, verdict manifests and identity-provider configuration;
P13 cache-bounded: issued tokens, refresh tokens and sessions, reclaimed only after recorded expiry and the documented post-expiry window;
P13 operational-state: OIDC token/refresh-token rows, with unknown expiry preserved and reported by Doctor;
P13 append-audit:
login_attempts, with Q-50’s classified retention windows, and the three deprecated local-audit tables when retained by an upgraded estate. Conditional comments on the deprecated tables 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 (folded): the folded surface uses Authority’s own connection and the separate
issuerschema;AddFoldedIssuerDirectoryfails startup when the fold is enabled and no connection is available. Direct in-memory repository registration is blocked unless the caller uses the explicit local/test gate.
6) Key management & rotation
- Keep the previous signing key published during rotation (
Authority:Signing:AdditionalKeys); tokens carrykid. - The default algorithm is ES256 (
AuthoritySigningOptions.Algorithm). - Rotation runs through
POST /internal/signing/rotate(bootstrap tier) — runbookoperations/key-rotation.md. A rotation cadence is an operator policy; none is enforced in source. - 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).
- Performance targets are not specified or measured in this repository.
8) Security posture
- TLS: Kestrel serves the configured certificate (
Kestrel__Certificates__Default__*in compose); TLS policy beyond that is deployment configuration. - mTLS sender constraint where configured (
Authority:Security:SenderConstraints:Mtls,EnforceForAudiences). - Replay protection: DPoP
jtireplay cache and nonce challenges (Authority:Security:SenderConstraints:Dpop:Nonce,RequiredAudiences). - Rate limits: fixed-window limits for the token, authorize and internal endpoints (
Authority:Security:RateLimiting). - Secrets: confidential clients authenticate with client secrets (hashed at rest; rotation grace in § 10), optionally sender-constrained with DPoP.
- PKCE is required for the authorization-code flow; the interactive login shell is stateless (no SSO cookie).
- 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") with EnableAuthorizationEndpointPassthrough() in Program.cs — 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. On the/console/adminand/api/v1/tenantsgroups the filter itself refuses (new TenantHeaderFilter(requireHeader: true), PCD-868); their handlers never fall back to atenantIdquery value or thedefaulttenant. On the other groups the filter passes through without a resolved tenant and the handler’s null-check produces the 400 with its endpoint-specific audit event. - Header present + bearer’s allowed-tenants list non-empty + header value not in the list →
403 forbidden. - Header present + header value different from the bearer’s
stellaops:tenantclaim (case-insensitive), including a bearer with no tenant claim at all (PCD-868, the PCD-873 finding) →403 forbidden, unless the caller is a global admin. The global-admin check (TenantHeaderFilter→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/tenants/* | ConsoleAdminEndpointExtensions.MapConsoleAdminEndpoints (header required) |
/console/admin/users/* | same |
/console/admin/roles/* | same |
/console/admin/clients/* | same |
/console/admin/tokens/* | same |
/console/admin/audit/* | same |
/api/v1/tenants/{tenantId}/* | TenantComplianceProfileEndpointExtensions.MapTenantComplianceProfileEndpoints (header required) |
/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’s per-tenant token handler, nowHubServiceTokenHandler). 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 catalog:replicate, 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.catalog:replicateis used by the air-gap posture replica (Airgap/AuthorityPostureReplication.cs), which rides the same host client. - 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, a dated decision record (2026-06-11). Its scope list is the original four; the two host-level grants above were added later and are not back-edited into it.
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). There is no/api/admin/*alias.
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
GET /internal/plugins/status and POST /internal/plugins/probe are mapped separately, independent of Authority:Bootstrap:Enabled, and report canonical plugin runtime status. They are not anonymous: status requires ops.health (the same authority.ops.health policy as the Doctor surface) and the probe, which runs every loaded plugin’s health check, requires ops.admin. No in-product caller uses them over HTTP; the Console identity-provider admin surface calls the reporter in process. GET /api/v1/claims/overrides returns the Authority-managed endpoint claim overrides (Authority:Router:ClaimsOverrides) consumed by the router gateway. It requires policy authority.claims-overrides.read, i.e. the machine-only scope authority:claims-overrides.read, granted only to the gateway’s stellaops-gateway-claims client-credentials identity; an anonymous caller outside the resource-server bypass networks receives 401.
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/tokens, POST /console/admin/tokens/revoke and GET /console/admin/audit act on the caller’s stellaops:tenant claim; a global administrator may select another tenant with the validated X-StellaOps-TenantId header. No query parameter selects a tenant (PCD-876 removed the tenantId query, which accepted any tenant in the stellaops:allowed_tenants claim, the client’s list for a password-grant token), and a request that resolves no tenant returns HTTP 400 tenant_required.
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 (curated OAuth contract), the generated OpenAPI document, and the build-info endpoint (module name authority). The Doctor checks route (MapServiceDoctorEndpoints) requires ops.health. (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)
The PCD-340 startup convergence and enumeration-failure behavior are source-verified against 76be17ca49 (2026-10-05). Re-verify with Authority’s TenantAdminApi_AndStartupConvergence_GrantPolicyEngineTenantTokens and TenantClientGrantServiceTests through tools/scripts/test-targeted-xunit.ps1. Shared-estate deployment acceptance remains a separate release-window check.
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 client edit used by earlier onboarding flows. The mechanism has four layers:
Declarative source of truth —
authority.clients.is_first_party— aBOOLEAN NOT NULL DEFAULT FALSEcolumn created by the consolidated baseline001_v1_authority_baseline.sql(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 = TRUEfor 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 service clients such aspolicy-engineandstellaops-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).
A store enumeration failure propagates to the admin API’s
503grant failure response, rather than becoming an empty successful grant. The tenant write may already have committed; the audit and warning identify incomplete onboarding.- 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.Startup convergence —
TenantClientGrantStartupService— registered after plugin bootstrap, it pages through enabled tenants in Authority’s own repository and applies the same grant service in a DI scope. This covers seeded tenants and clients declared after tenant creation. The global disable and explicit target override remain authoritative. Missing explicit clients or unresolved grant failures stop startup; no sibling database is read or written. Existing grants are preserved, and disabled tenants are excluded from this pass.
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 (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
Protocol logging ownership. Authority constructs a Serilog pipeline for each host with
preserveStaticLogger: true. The host’s Microsoft logger factory and request diagnostic context use that pipeline; another host’s startup or disposal cannot redirect its events or close its sinks. TheOpenIddictcategory remains at Warning because its Information events serialize protocol fields. Authority’s credential-safeAuthorization request validatedevent remains available. Verified against source commit8561ab0cee(2026-10-04, PCD-263). Re-verify the concurrent exchanges, both disposal orders, sink ownership and PKCE/client-secret/ code/refresh-token exclusion withpwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj -Class StellaOps.Authority.Tests.Integration.EncryptedTokenRedemptionTests -BuildProjectReferences.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:
AuthorityAuditSinkfirst persists authentication and security events inauthority.login_attempts, then optionally emits to the Timeline unified sinktimeline.unified_audit_events. Optional emission failures do not remove the original record. Events include token issuance, revocations and admin changes; local disposal follows the two configured login-attempt retention windows.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.Tracing: token flows, DB reads, JWKS cache.
14) Configuration (YAML)
Authority binds the Authority section (StellaOpsAuthorityOptions, src/__Libraries/StellaOps.Configuration/). Sources, in order: optional YAML files ../etc/authority.yaml, ../etc/authority.local.yaml, authority.yaml and authority.local.yaml relative to the content root, then environment variables with the STELLAOPS_AUTHORITY_ prefix (for example STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING, which compose sets from STELLAOPS_POSTGRES_AUTHORITY_CONNECTION). Clients are not configured here: first-party clients come from the Standard-plugin bootstrapClients file (§ 1).
The example shows the security options with their key names (values are illustrative, not defaults):
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"
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 | Client secret (hashed) or mTLS certificate binding; client registration & secret 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 |
| Account enumeration | Sign-in answers and response time | One answer for unknown, wrong-password, disabled and locked sign-ins; equal hash work (§ 3.2d) |
| Downgrade to bearer | Strip DPoP | Resource servers require DPoP/mTLS based on aud; reject bearer without cnf |
17) Deployment
- One
authorityservice per estate (release bundledevops/release/bundle/docker-compose.yml; dev estatedevops/compose/docker-compose.stella-services.ymlwith thedocker-compose.authority.ymloverlay), aliasauthority.stella-ops.local, imagestellaops/authority, health/health. - DB: own database; compose maps
STELLAOPS_POSTGRES_AUTHORITY_CONNECTIONintoAuthority:Storage:ConnectionStringand refuses to render when it is unset; Valkey for DPoP nonce/replay state (Authority:Security:SenderConstraints:Dpop:Nonce:Store). - Config mounts:
config/etc/authority→/app/etc/authority(Standard-pluginbootstrapClients, plugin YAML); signing and ack-token keys from mounted files (Authority:Signing:KeyPath). - Backups: operations/backup-restore.md.
18) Implementation notes
- Stack: .NET with OpenIddict server in degraded mode (
AddOpenIddict().AddServer(...)), custom DPoP validation and mTLS binding handlers. - Client library:
StellaOps.Auth.Client(token acquisition, file/messaging token caches).
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)",
"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.
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, lockout state, login attempts |
| Sessions & tokens | Critical | sessions, tokens, refresh_tokens, oidc_tokens, oidc_refresh_tokens, revocations |
| Roles & Permissions | High | roles, permissions, role_permissions, user_roles (Console RBAC catalogue) |
| Clients | High | clients, client_tenants, service_accounts, api_keys |
| Tenants | High | tenants registry, cross-tenant isolation boundaries |
| Identity providers | High | identity_provider_configs (process-global) |
| Break-glass evidence | High | break_glass_events (append-audit) |
Authority authentication and security events are stored first in authority.login_attempts; optional Timeline emission supplies the unified audit surface. The deprecated local authority.audit, airgap_audit and offline_kit_audit tables remain dropped.
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 into the Authority family by Sprint 216).
The folded issuer surface requires PostgreSQL persistence; in-memory repositories remain an explicit local/test harness path and cannot be registered through the default infrastructure extension.
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/(Core and Infrastructure projects) andsrc/Authority/__Libraries/StellaOps.IssuerDirectory.Api/(endpoints and the fold composition root) - Persistence:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/ - Tests:
src/Authority/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/ - Client library:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Client/(consumed by Policy, ReleaseOrchestrator and the DeltaVerdict library) - 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 froze the standalone host on 2026-09-11 intosrc/__Obsoleted/Authority/StellaOps.IssuerDirectory.WebService/; the Authority host is the only runtime. The fold is switched byIssuerDirectory:Fold:Enabled: the dev estate and current bundle source setISSUERDIRECTORY__FOLD__ENABLED(defaulttrue) fromdevops/compose/docker-compose.authority.yml, while the option’s source default remainsfalse(IssuerDirectoryFoldOptions). The bundle source also selects that overlay’s combined CA bind for Authority so its own HTTPS issuer metadata can be validated against the installer’s generated certificate. On the isolatedpcd175run006install, adding only that bind to the existing Authority container produced an authenticatedGET /issuer-directory/issuers200 and an anonymous 401; see PCD-39 live evidence (pruned evidencedocs/implplan/_evidence/20260930-pcd39-issuer-directory/README.mdat2c9ee615af). This source and local-install proof does not change the previously signed v1.0.0 archive. The API paths, scopes, client contract, and separateissuerschema are preserved.
21.5 Direct-caller target configuration
Verified against source 7e3898fbce (CCP-081, 2026-10-13), extending PCD-377’s target contract. Canonical Compose configures Platform’s STELLAOPS_ISSUERDIRECTORY_URL and Policy’s IssuerDirectory__Client__BaseAddress through AUTHORITY_ISSUER_DIRECTORY_DIRECT_BASE_ADDRESS, defaulting to http://authority.stella-ops.local. Policy’s configured client reads operator keys with its existing tenant-bound issuer-directory:read service bearer. CCP-081 extends canonical Compose and both generated bundles to configure Release Orchestrator’s client with the same target switch and the operator-signing namespace. Its key read reuses the host’s configured service identity and requests the same read scope for the request tenant. The separate R9 grant and deployment are still required; changing source configuration does not grant access to a running client. The retired Excititor hosts have no current address.
The generated bundle and digest template preserve the same expression. Both local environment templates and the bundle .env.example declare the same Authority default. An explicit nonempty override moves every configured caller together; an empty value uses the fallback. Changing this address does not change the gateway route, fold enablement, resource authentication, scope grants, database owner or trust configuration.
Re-verify with python devops/release/bundle/tools/test_bundle_direct_caller_target.py -v and bash tools/scripts/validate/check-bundle-generated.sh. The regression checks exact caller rosters and runs Docker Compose interpolation on only the public target scalars from all four source/generated surfaces. Complete-model generation and key-register checks cover their configuration shape. This is configuration proof; no deployment or network call to the synthetic override host is performed. The current caller dispositions are also recorded in the fold runbook.
Evidence
| Claim | Source |
|---|---|
Host, OpenIddict endpoints (/token, /authorize, /introspect, /revoke, /logout, /jwks and /connect/* forms), enabled grants, lifetimes wiring, degraded mode, scope registration, /health, /ready, /internal/*, /api/v1/claims/overrides, Doctor route, build-info, configuration sources and STELLAOPS_AUTHORITY_ prefix | src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs |
| Per-host log routing and sink disposal, concurrent protocol exchanges and credential exclusion | src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs, src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Integration/EncryptedTokenRedemptionTests.cs |
| Interactive login shell and asset aliases | src/Authority/StellaOps.Authority/StellaOps.Authority/AuthorizeEndpoint.cs |
| End-session | src/Authority/StellaOps.Authority/StellaOps.Authority/EndSessionEndpoint.cs, OpenIddict/Handlers/ |
| Console and Console admin routes, fresh-auth, audit list deprecation, identity-provider admin | src/Authority/StellaOps.Authority/StellaOps.Authority/Console/ConsoleEndpointExtensions.cs, Console/Admin/ConsoleAdminEndpointExtensions.cs, Console/Admin/IdentityProviderAdminEndpointExtensions.cs, Console/Admin/ConsoleBrandingEndpointExtensions.cs, Console/TenantHeaderFilter.cs |
| Tenant compliance-profile, assurance-pack and submission-authorization routes | src/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantComplianceProfileEndpointExtensions.cs |
| Air-gap and incident audit groups | src/Authority/StellaOps.Authority/StellaOps.Authority/Airgap/AirgapAuditEndpointExtensions.cs, Observability/IncidentAuditEndpointExtensions.cs |
| Revocation status | src/Authority/StellaOps.Authority/StellaOps.Authority/Revocation/RevocationStatusEndpointExtensions.cs |
| Options and defaults (lifetimes, signing, DPoP, nonce, mTLS, rate limits, vuln workflow tokens) | src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.Core.cs, AuthoritySigningOptions.cs, AuthorityDpopOptions.cs, AuthorityDpopNonceOptions.cs, AuthorityMtlsOptions.cs, AuthorityRateLimitingOptions.cs, AuthorityVulnAntiForgeryOptions.cs, AuthorityVulnAttachmentOptions.cs |
| Scope catalogue | src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs |
| Claim names | src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsClaimTypes.cs, StellaOpsAuthenticationAssurance.cs |
| Resource-server validation, clock skew, bypass modes, fresh-auth gate | src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/ (StellaOpsResourceServerOptions.cs, StellaOpsBypassMode.cs, FreshAuthenticationGate.cs, ServiceCollectionExtensions.cs) |
| Identity-provider plugins | src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard, .Plugin.Ldap, .Plugin.Oidc, .Plugin.Saml, .Plugin.Harness, .Plugins.Abstractions |
Standard-plugin bootstrap (admin, clients, public-origin expansion, plan) | src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/StandardPluginOptions.cs, Bootstrap/StandardPluginBootstrapper.cs, devops/etc/authority/plugins/standard.yaml, devops/etc/authority.plugins/standard.yaml |
Schema authority, migrations, tenant-scoped migration execution | src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/, Postgres/AuthorityMigrationSqlExecutor.cs |
| Folded issuer directory | src/Authority/__Libraries/StellaOps.IssuerDirectory.Api/Fold/IssuerDirectoryFold.cs, src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/, src/__Obsoleted/Authority/StellaOps.IssuerDirectory.WebService/AGENTS.md |
| Curated OpenAPI contract | src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml |
Deployment wiring (env, mounts, STELLAOPS_POSTGRES_AUTHORITY_CONNECTION, STELLAOPS_CONSOLE_PUBLIC_ORIGINS, access-token lifetime override) | devops/release/bundle/docker-compose.yml, devops/compose/docker-compose.stella-services.yml, devops/compose/docker-compose.authority.yml |
| Break-glass introspection consumer | src/Router/StellaOps.Gateway.WebService/ (BreakGlassIntrospectionMiddleware, GatewayBreakGlassIntrospectionIdentityCheck) |
