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 3372e963a8 and the 2026-09-30 isolated-run receipt linked in §21. Re-verify with python devops/release/bundle/tools/test_bundle_issuer_directory_fold.py and bash 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.

Internal components (what the service actually contains).


1) Protocols & cryptography

Source-of-truth note (Sprint 20260505_002). devops/etc/authority/plugins/standard.yaml is the canonical, tracked-in-git bootstrap source for first-party clients. The authority service in devops/compose/docker-compose.stella-services.yml bind-mounts the host directory devops/etc/authority/ read-only into the container at /app/etc/authority/, and the stellaops/authority:dev image ships nothing under that path — so this host file IS the runtime config. A previous broad plugins/ rule in .gitignore accidentally hid this file; an explicit !devops/etc/authority/plugins/** re-include keeps the file tracked while leaving other runtime-drop plugins/ directories ignored. Edits made directly to this file take effect on the next Authority restart (StandardPluginBootstrapper reconciles authority.clients against the YAML on every start). The sibling devops/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.yml or docker-compose.plugins.harness.yml is selected, Authority overrides Authority:PluginDirectories:0 to /app/plugins/authority but inherits the base Authority:Plugins:ConfigurationDirectory=/app/etc/authority/plugins. Standard bootstrap plus LDAP/OIDC/SAML/harness/registry YAML therefore share the single canonical devops/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 the harness descriptor 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 by System.DirectoryServices.Protocols (libldap-2.5.so.0/liblber-2.5.so.0). The Docker build helpers pass EXTRA_RUNTIME_PACKAGES only for the authority service key, defaulting AUTHORITY_LDAP_NATIVE_PACKAGE to libldap-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. AuthorityProviderAdmission rejects 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. AuthorityPluginLoader evaluates catalog admission before invoking any registrar, and preserves PluginHost signature/version/load failures as mounted-but-rejected results. An enabled store descriptor with no registered signed runtime bundle reports Mounted:false, Discovered:false, and PluginProbeOutcome.NotMounted; a discovered bundle rejected by admission or signature validation reports Mounted:true, Discovered:true, Admitted:false, and PluginProbeOutcome.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 DatabaseIdentityProviderDescriptorSource after Authority startup migrations and before plugin option validation. Operator-configurable provider types are exactly ldap, oidc, and saml; ad is not an Authority type, while standard, 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-in standard descriptor 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/reload reports 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.yaml therefore 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 the mfa capability 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_idPurposeMigration
defaultCanonical 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)
installationScope 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 into 001_v1_authority_baseline.sql. The originals live under Migrations/_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:

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).


2) Token model

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 distinct aud value), not a fixed signer|scanner|... enum. Real first-party clients declare audiences such as stellaops, signer, notify, api://export-center, api://release-orchestrator, api://findings-ledger, api://integrations, and api://reachgraph (see devops/etc/authority/plugins/standard.yaml).

Sender-constraint (cnf)

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)

2.3 ID tokens (optional)


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.

Conventional aliases (/connect/*). Authority exposes /connect/authorize directly through the same custom interactive login handler as /authorize, /connect/logout through the same end-session handler as /logout, and OpenIddict also serves /connect/token, /connect/introspect, /connect/revoke and /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)

3.2 Token issuance

The legacy /oauth/token, /oauth/introspect and /oauth/revoke aliases and their Authority:ApiLifecycle:LegacyAuth configuration were removed on 2026-09-27 (PCD-78). Clients call /token, /introspect and /revoke. See docs/api/authority-legacy-auth-endpoints.md.

DPoP handshake (example)

  1. Client prepares JWK (ephemeral keypair).

  2. 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.

  3. Authority validates proof; issues access token with cnf.jkt=<thumbprint(JWK)>.

  4. Client uses the same DPoP key to sign every subsequent API request to services (Signer, Scanner, …).

mTLS flow

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 checkpoint 1e5f3b3053d0682d1b3a3565d4fcfa182f67e3d9, ordinary Console browser login and all 16 exact-120-scope protocol controls also passed. See the login receipt (pruned evidence docs/implplan/_evidence/20260831-auth16-console-login.md at 5a20d13f8a). 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 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):

  1. the tenant the caller names: the tenant parameter of the password grant or of /authorize, or the stellaops:tenant claim of the authorization code or refresh token being redeemed;
  2. the provider’s configured tenantId;
  3. 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:

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):

PathWhere it is refusedCaller sees
Password grant (POST /token, grant_type=password)Standard provider VerifyPasswordAsync, after the password checkinvalid_grant, the same description as a wrong password
Interactive sign-in (POST /authorize, /connect/authorize)the same provider checkthe login form with “Invalid username or password.”, no code
Authorization-code redemption (grant_type=authorization_code)ValidateAccessTokenHandler, before the code is redeemedinvalid_grant, “The grant is no longer valid.”
Refresh (grant_type=refresh_token)ValidateAccessTokenHandler, before the token is redeemedinvalid_grant, “The grant is no longer valid.”
Introspection (POST /introspect) of the account’s tokensValidateAccessTokenHandleractive: false

Details:

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):

PathCaller 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:

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)

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:

  1. A revoked, expired or unknown token answers 200 {"active": false}. The gateway refuses the request 401.
  2. A wrong or unknown client, or a wrong secret, answers 401. The gateway’s Doctor check doctor.router.break-glass.introspection-identity reads that as a rejected credential.
  3. 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.
  4. 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. Only stellaops-break-glass-operator names the introspection client in its allowedAudiences.

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).

3.4 UserInfo (roadmap — not implemented)


3.5 Vuln Explorer workflow safeguards


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.

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, email, profile, offline_access) concatenated with StellaOpsScopes.All (RegisterScopes in Program.cs), so those four standard scopes plus every StellaOpsScopes const are the strings that may legitimately appear in a scope claim (the developer/viewer allow-lists in AuthorityHumanScopeResolver.cs explicitly 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).

ScopeServiceOperation
signer:sign / signer:read / signer:rotate / signer:adminSignerDSSE signing, key metadata, rotation, admin
attest:create / attest:read / attest:adminAttestorCreate/read attestation records, admin
scanner:scanScanner.WebServiceSubmit scan jobs
scanner:exportScanner.WebServiceExport SBOMs
scanner.signed-sbom-material.writeScanner.WebServiceWrite signed-SBOM material
scanner:readScanner.WebServiceRead catalog/SBOMs
scanner:writeScanner.WebServiceUpdate scanner settings
policy:preview:invokePolicy EngineInvoke the isolated Scanner Policy-owner preview contract
vex:read / vex:ingest / vex.adminExcititorQuery / raw ingest / operate
advisory:read / advisory:ingestConcelierRead / raw advisory ingest
concelier.jobs.read / concelier.jobs.trigger / concelier.mergeConcelierRead job/exporter state / trigger jobs / manage merges
ui.read / ui.adminUIView/admin
authority:tenants.read / authority:tenants.writeAuthorityTenant catalog admin
authority:users.read / authority:users.writeAuthorityUser admin
authority:idp.read / authority:idp.writeAuthorityRead / mutate global identity-provider configuration
authority:roles.read / authority:roles.writeAuthorityRole/scope admin
authority:clients.read / authority:clients.writeAuthorityClient admin
authority:tokens.read / authority:tokens.revokeAuthorityToken inventory and revoke
authority.revocation.readAuthorityRevocation bundle status metadata
authority.audit.readAuthorityAudit log read (note: dot, authority.audit.read)
authority:branding.read / authority:branding.writeAuthorityBranding admin
zastava:read / zastava:trigger / zastava:adminZastavaRead 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.*.manage consts. StellaOpsScopes still defines authority.users.manage and authority.clients.manage. The live admin surfaces gate on the granular authority:users.read/write and authority:clients.read/write scopes 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:read scopes verbatim and adds no new entry to StellaOpsScopes, 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 roleScheduler scope claim values that may be issuedConsole contract
Release Operations Steward (role/release-operations-steward)scheduler:read, scheduler:operateMay see Scheduler read surfaces and operating controls.
Service Release Contributor (role/service-release-contributor)scheduler:read onlyMay 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 claimProfessional roles that receive itReason
release.policy.readExecutive Assurance Viewer, Release Operations StewardThe 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.manageNoneA 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:writeNoneCurrent-tenant telemetry consent is a tenant administration decision.
platform:federation:read, platform:federation:writeNoneInstallation 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.

SurfaceDefinesRead atSource 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

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:

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:


6) Key management & rotation


7) HA & performance


8) Security posture

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:

RoleReplay layerWhere enforced
Authority as OIDC IdP (issuing OpToks)OpenIddict server: PKCE + persisted JTI + one-use redemptionRequireProofKeyForCodeExchange() 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 cacheStellaOps.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 cacheStellaOps.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.

CallerStrict (default)LegacyDisabled
Bearer/DPoP token (has Authorization)never bypassed (unchanged)never bypassednever bypassed
Gateway identity envelope presentnever bypassed — envelope scopes/tenant are authoritative; an envelope with no scopes authorizes nothingbypassednever bypassed
Authenticated principal missing a scope, or carrying a disallowed tenantrefused (403)bypassedrefused
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 demandbypassednever bypassed
Anonymous, token-less, envelope-less call from a listed CIDRbypassed (deliberate residual — see below)bypassedrefused

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

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):

  1. Header missing or whitespace → 400 tenant_header_missing. On the /console/admin and /api/v1/tenants groups the filter itself refuses (new TenantHeaderFilter(requireHeader: true), PCD-868); their handlers never fall back to a tenantId query value or the default tenant. 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.
  2. Header present + bearer’s allowed-tenants list non-empty + header value not in the list → 403 forbidden.
  3. Header present + header value different from the bearer’s stellaops:tenant claim (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 the stellaops:admin=true claim or the persisted authority.users.is_global_admin=true row for the subject — the same claim-OR-row check applied to all tenant-scoped console routes, not only GET /console/tenants.
  4. Header present and consistent with the principal → resolved tenant is stored in HttpContext.Items["__authority-console-tenant"] for the handler.
  5. Anonymous (unauthenticated) requests → 401 unauthorized (auth middleware short-circuits before this filter runs).

Endpoint inventory (tenant-scoped, header-required):

Group / endpointFilter wired by
/console/profileConsoleEndpointExtensions.MapConsoleEndpoints
/console/tenantssame
/console/dashboardsame
/console/filterssame
/console/vuln/*same
/console/vex/*same
/console/token/introspectsame
/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:

  1. Mints a per-tenant console-workspace client-credentials token (ConsoleWorkspaceTokenProvider, lifted from the Scanner worker’s per-tenant token handler, now HubServiceTokenHandler). The downstream-call tenant comes from TenantHeaderFilter.GetTenant(httpContext) (already validated against the inbound envelope), and the tenant form param drives the minted token’s stellaops:tenant claim. The service account is least-privilege: scopes vuln:view scheduler:read policy:run aoc:verify platform:doctor:register catalog:replicate, audiences api://findings-ledger stellaops api://scheduler, a named-tenant allow-list (never wildcard), secret auto-managed via standard.yaml bootstrapClients (CONSOLE_WORKSPACE_CLIENT_SECRET, Vault vault://secret/stellaops/console-workspace#secret). A mint failure (tenant off the allow-list) fails closed to a 502-class — never sample data. platform:doctor:register is 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:replicate is used by the air-gap posture replica (Airgap/AuthorityPostureReplication.cs), which rides the same host client.
  2. 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 stamps HttpContext.Items["stellaops:onBehalfOf"] without replacing the principal (the service token stays the authZ principal; the on-behalf-of actor is audit-only). AuditActionFilter then records the real operator as Actor.Id with details.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 OnBehalfOfActorMiddleware registration 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):

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:

  1. Declarative source of truth — authority.clients.is_first_party— a BOOLEAN NOT NULL DEFAULT FALSE column created by the consolidated baseline 001_v1_authority_baseline.sql (it folded in the pre-1.0 S032_clients_is_first_party_column.sql). The baseline also creates a partial index idx_clients_is_first_party WHERE is_first_party = TRUE for the runtime auto-grant query.

    The migration backfills is_first_party = TRUE for the known platform-shipped first-party multi-tenant clients:

    • stellaops-cli (operator/QA password grant)
    • stellaops-cli-automation (CI/automation client_credentials, Pattern B per Sprint 051)
    • stellaops-scanner-worker (Scanner/SBOM worker client_credentials, Pattern B)
    • stellaops-release-dispatch (cross-tenant release/orch operator, Pattern B per Sprint 059)

    Standard-plugin bootstrapClients may also opt a platform client in with firstParty: true. BootstrapClientOptions.ToRegistration carries that flag, and the provisioning UPSERT union-preserves it. This is the path used by newer service clients such as policy-engine and stellaops-findings-security-internal; clients without a migration, YAML flag, or operator opt-in stay FALSE.

  2. Runtime resolution — TenantClientGrantService— when the admin endpoint commits a new tenant row, it calls ITenantClientGrantService.GrantAsync(tenantSlug). That service resolves its target client-id set at grant time:

    • If Authority:TenantClientGrants:AutoGrantClientIds is 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 with is_first_party = TRUE. Each matched client’s Properties[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 503 grant failure response, rather than becoming an empty successful grant. The tenant write may already have committed; the audit and warning identify incomplete onboarding.

  3. 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/tenants request will auto-extend them. No service rebuild, no appsettings*.json edit, no Authority restart is required. The decision lives next to the client definition.

    When adding a new platform-shipped first-party client, declare firstParty: true in 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.

  4. 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:

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.

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 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:

  1. Verify JWT signature (kid in JWKS), iss, aud, exp, nbf.

  2. Enforce sender-constraint:

    • DPoP: validate DPoP proof (htu, htm, iat, jti) and match cnf.jkt; cache jti for replay defense; honor nonce challenges.
    • mTLS: match presented client cert thumbprint to token cnf.x5t#S256.
  3. Check scopes; optionally map to internal roles.

  4. Check tenant (stellaops:tenant) and project (stellaops:project) as appropriate.

  5. For Signer only: require both OpTok and PoE in the request (enforced by Signer, not Authority).


12) Error surfaces & UX


13) Observability & audit


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)

ThreatVectorMitigation
Token theftCopy of JWTShort TTL, sender-constraint (DPoP/mTLS); replay blocked by jti cache and nonces
Replay across hostsReuse DPoP proofEnforce htu/htm, iat freshness, jti uniqueness; services may require nonce
ImpersonationFake clientClient secret (hashed) or mTLS certificate binding; client registration & secret rotation
Key compromiseSigning key leakHSM/KMS storage, key rotation, audit; emergency key revoke path; narrow token TTL
Cross-tenant abuseScope elevationEnforce aud, stellaops:tenant (+ optional stellaops:project) at issuance and resource servers
Account enumerationSign-in answers and response timeOne answer for unknown, wrong-password, disabled and locked sign-ins; equal hash work (§ 3.2d)
Downgrade to bearerStrip DPoPResource servers require DPoP/mTLS based on aud; reject bearer without cnf

17) Deployment

18) Implementation notes


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 groupSecurity classificationContent
UsersCriticalPassword hashes, lockout state, login attempts
Sessions & tokensCriticalsessions, tokens, refresh_tokens, oidc_tokens, oidc_refresh_tokens, revocations
Roles & PermissionsHighroles, permissions, role_permissions, user_roles (Console RBAC catalogue)
ClientsHighclients, client_tenants, service_accounts, api_keys
TenantsHightenants registry, cross-tenant isolation boundaries
Identity providersHighidentity_provider_configs (process-global)
Break-glass evidenceHighbreak_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 groupSecurity classificationContent
IssuersMediumPublisher identity, display name, homepage, tenant scope
Issuer KeysMediumPublic key material (Ed25519, X.509, DSSE), fingerprints, key lifecycle
Issuer AuditMediumCRUD 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:

21.4 IssuerDirectory domain ownership

As of Sprint 216, the IssuerDirectory source tree is owned by the Authority domain:

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

ClaimSource
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_ prefixsrc/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs
Per-host log routing and sink disposal, concurrent protocol exchanges and credential exclusionsrc/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs, src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Integration/EncryptedTokenRedemptionTests.cs
Interactive login shell and asset aliasessrc/Authority/StellaOps.Authority/StellaOps.Authority/AuthorizeEndpoint.cs
End-sessionsrc/Authority/StellaOps.Authority/StellaOps.Authority/EndSessionEndpoint.cs, OpenIddict/Handlers/
Console and Console admin routes, fresh-auth, audit list deprecation, identity-provider adminsrc/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 routessrc/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantComplianceProfileEndpointExtensions.cs
Air-gap and incident audit groupssrc/Authority/StellaOps.Authority/StellaOps.Authority/Airgap/AirgapAuditEndpointExtensions.cs, Observability/IncidentAuditEndpointExtensions.cs
Revocation statussrc/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 cataloguesrc/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
Claim namessrc/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsClaimTypes.cs, StellaOpsAuthenticationAssurance.cs
Resource-server validation, clock skew, bypass modes, fresh-auth gatesrc/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/ (StellaOpsResourceServerOptions.cs, StellaOpsBypassMode.cs, FreshAuthenticationGate.cs, ServiceCollectionExtensions.cs)
Identity-provider pluginssrc/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 executionsrc/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/, Postgres/AuthorityMigrationSqlExecutor.cs
Folded issuer directorysrc/Authority/__Libraries/StellaOps.IssuerDirectory.Api/Fold/IssuerDirectoryFold.cs, src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/, src/__Obsoleted/Authority/StellaOps.IssuerDirectory.WebService/AGENTS.md
Curated OpenAPI contractsrc/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 consumersrc/Router/StellaOps.Gateway.WebService/ (BreakGlassIntrospectionMiddleware, GatewayBreakGlassIntrospectionIdentityCheck)