Authority — Architecture

Audience: platform engineers, integrators, and security reviewers who need to understand how Stella Ops authenticates callers.

Stella Ops Authority is the on-prem OIDC/OAuth2 service that issues short-lived, sender-constrained operational tokens (OpToks) to first-party services and tools. It is the trust anchor for who is calling inside a Stella Ops installation. Entitlement is proven separately by Proof-of-Entitlement (PoE) from the cloud Licensing Service — Authority does not issue PoE.

Scope. Implementation-ready architecture for Authority: protocols (DPoP & mTLS binding), token shapes, endpoints, storage, rotation, HA, RBAC, audit, and testing. This dossier consolidates identity and tenancy requirements previously spread across the AOC, Policy, and Platform guides plus the dedicated Authority implementation plan.

Related references

  • Tenant-selection ADR: docs/architecture/decisions/ADR-002-multi-tenant-same-api-key-selection.md
  • Service impact ledger: docs/technical/architecture/multi-tenant-service-impact-ledger.md
  • Flow sequences: docs/technical/architecture/multi-tenant-flow-sequences.md
  • Rollout policy: docs/operations/multi-tenant-rollout-and-compatibility.md
  • QA matrix: docs/qa/feature-checks/multi-tenant-acceptance-matrix.md

0) Mission & boundaries

Mission. Provide fast, local, verifiable authentication for Stella Ops microservices and tools by minting very short-lived OAuth2/OIDC tokens that are sender-constrained (DPoP or mTLS-bound). Support RBAC scopes, multi-tenant claims, and deterministic validation for APIs (Scanner, Signer, Attestor, Excititor, Concelier, UI, CLI, Zastava).

Boundaries.

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 (line 58) 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 legacy devops/etc/authority.plugins/standard.yaml (note the dot) carries only password policy and is not read by compose — it remains as a fallback example for non-compose deployments.

Pluginized runtime overlays are a separate executable-bundle lane. When docker-compose.plugins.recommended.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 remain under devops/plugins/authority/<profile>/<plugin-id>/. 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, lines 726-729)
installationScope key used by platform.setup_sessions (PlatformSetupService.InstallationScopeKey). Not currently FK-referenced by Authority, but seeded defensively so cross-service writes that join on authority.tenants.tenant_id remain FK-safe.same migration

Migration-filename note (2026-07-12). The pre-1.0 chain (including the former 003_seed_default_tenants.sql) was collapsed 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.2 Platform-side tenant propagation

Authority owns tenant lifecycle writes, but platform-side services resolve tenant slugs through shared.tenants in the platform database. Console-admin tenant writes therefore follow this sequence:

sequenceDiagram
    participant Operator
    participant Authority as Authority console-admin API
    participant AuthDb as authority.tenants
    participant Propagator as ISharedTenantsPropagator
    participant PlatformDb as shared.tenants
    participant Audit as Auth audit sink

    Operator->>Authority: create/update/suspend/resume tenant
    Authority->>AuthDb: persist authority tenant row
    Authority->>Propagator: UpsertAsync(updated tenant)
    Propagator->>PlatformDb: INSERT ... ON CONFLICT DO UPDATE
    Authority->>Audit: tenant.* with tenant.propagation.outcome
    Authority-->>Operator: result, plus Warning header on partial propagation

ISharedTenantsPropagator never sets is_default=true; the existing default tenant owns the partial unique index on shared.tenants(is_default). Propagation failures do not roll back the Authority write. The API returns the successful Authority result with a Warning header, emits an *.propagation_failed audit event, and expects the operator to run devops/compose/scripts/reconcile-tenants.* or enable Authority:Propagation:OnOrphan=reconcile for startup convergence.

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.

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 (kept in sync with shared.tenants.status by ISharedTenantsPropagator) 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 — this contract applies to the password grant only.


2) Token model

2.1 Access token (OpTok) — short-lived (120–300 s)

Registered claims

iss   = https://authority.<domain>
sub   = <client_id or user_id>
aud   = <one or more declared audiences, e.g. "signer", "stellaops", "api://export-center">
exp   = <unix ts>  (<= 300 s from iat)
iat   = <unix ts>
nbf   = iat - 30
jti   = <uuid>
scope = "scanner:scan scanner:export signer:sign ..."

Audiences are projected from the client’s declared allowedAudiences (each entry becomes a 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:idp             = <identity provider name>    // originating plugin (standard|ldap|oidc|saml)
stellaops:service_account = <service account id>        // delegated client-credentials tokens
sid                       = <session id>                // interactive sessions

There is no inst/installation claim and no roles or plan claim in the issued token; role/scope resolution is collapsed into the scope claim at issuance. 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

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, and /connect/revoke. These are the externally-routed OIDC URIs in the compose stack; the un-prefixed paths remain supported for local/internal clients.

3.1a End-session (RP-Initiated Logout)

3.2 Token issuance

Legacy aliases under /oauth/token are deprecated as of 1 November 2025 and now emit Deprecation/Sunset/Warning headers. See docs/api/authority-legacy-auth-endpoints.mdfor timelines and migration guidance.

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.3 Introspection & revocation (optional)

Requests targeting the legacy /oauth/{introspect|revoke} paths receive deprecation headers and are scheduled for removal after 1 May 2026.

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.

Services must verify aud and sender constraint (DPoP/mTLS) per their policy.

First-party audience convergence (drift self-heal). The per-boot standard-plugin bootstrapper only reconciles clients listed in standard.yaml bootstrapClients; stella-ops-ui is not in that list, so its audiences are not re-merged from config on subsequent boots. When the canonical audience set grows (e.g. stellaops added to stella-ops-ui), long-lived volumes seeded under the old set would otherwise stay drifted — and a drifted gateway-fronted UI client breaks the whole console (IDX10214 → anonymous principal). The forward-only startup migration 007_authority_first_party_audience_convergence.sql reconciles this: it performs an additive union-merge of the configured first-party audiences into authority.clients.properties.audiences (it never removes audiences) so a drifted or never-converged volume heals on boot. Limitation: the migration runner only applies migrations not already recorded in authority.schema_migrations, so a drift introduced after 007 is recorded is not re-healed by a plain restart; re-converging such a volume requires re-running the reconcile (the migration’s intent is the historical/never-converged volume, not post-migration tampering).

4.2 Core scopes

Authoritative source. The complete, canonical scope catalog is the C# consts on StellaOpsScopes (src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs). OpenIddict registers the standard OIDC scopes (openid, email, profile, offline_access) concatenated with StellaOpsScopes.All (Program.cs:393-403), 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 AuthorizeEndpoint.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
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

Note on legacy authority.*.manage consts. StellaOpsScopes still defines authority.users.manage and authority.clients.manage (dotted, singular manage) for backward compatibility. 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 → scopes. The per-tenant authority.permissions/role_permissions catalog is RBAC-editor state only and is not consulted at issuance (see § 4.3 — that claim still holds). However, the interactive /authorize grant does perform role-based scope narrowing at issuance via ResolveUserGrantedScopes, so the older claim that issuance is entirely role-blind is false:

Two consequences worth pinning down:

  1. This narrowing is code-only RBAC (compiled role→scope allow-lists), a second source of truth alongside the deliberately issuance-inert authority.roles/role_permissions catalog — operators cannot see or edit it.
  2. It is applied only in the interactive /authorize path. The password and client-credentials grant handlers do not apply the same narrowing, so the same user can receive differently-scoped tokens depending on the flow. (See Architecture concerns.)

Executive Assurance Viewer is limited to the canonical assurance read bundle (ui.read, release/policy/findings/evidence/graph reads, policy/audit review, export viewing, deterministic replay verification through replay:read, tenant Timeline reads, platform context, and — BAZ-3 — attestor evidence-transparency / proof-chain reads through attest:read). It does not receive replay:write, vuln:operate, or the attestation-authoring attest:create. Security Risk Manager is limited to the canonical risk-analysis bundle (UI, Scanner read/scan, SBOM and registry inventory reads, findings/Vulnerability Explorer reads and investigation, policy read/simulation, evidence/graph, advisory, raw VEX evidence through vex:read, VexHub statement reads through the distinct vexhub:read claim, tenant Timeline reads, and — BAZ-3 — the remediation console read surface through remediation:read; the remediation:submit/remediation:manage write scopes stay admin-only). VexHub administration remains excluded. Its registry:read claim is limited to tenant-scoped inventory reads; registry cache/plan controls and integration writes remain excluded. Release Operations Steward and Service Release Contributor also carry timeline:read because their Console audit route is a tenant-scoped operational read; this does not grant Timeline writes, export initiation, retention administration, or cross-tenant access. Authorization-code issuance strips requested administrative, release write/publish, orchestration operate, Scheduler operate, policy approval, and exception approval scopes from both roles because those scopes are outside their shared Console-catalog bundles.

The Console Users update path treats every role returned by compiled GetDefaultRoles() as assignable even when no duplicate tenant-local role-table row exists. The compiled catalog remains authoritative for those built-in scope bundles; tenant-created roles must still resolve to persisted role rows. This matches the role-list/editor contract and avoids rejecting a built-in role that was already stored in user metadata and honored during token issuance.

The Scheduler portion of the professional-role contract is intentionally claim-driven and least-privilege:

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

4.3 authority.permissions vs. JWT scope claims (Sprint 20260512_027)

Two surfaces, two different jobs. They are easy to conflate but enforce very different things, and confusion has produced incorrect operator expectations (e.g. the MEMORY note that claimed “Authority bootstrap creates 149 permission rows” — actual count on the live dev stack: 10 until Sprint 20260512_027 landed).

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.S001_v1_authority_operational_baseline.sql preserves the historical S001-S033 seed lineage; fresh scope top-ups must be new forward S0xx_*.sql files after that collapsed sequence (for example S034_registry_read_scope.sql). The catalog is kept current by appending a seed row whenever a new const is added to StellaOpsScopes.

Token issuance path (AuthorizeEndpoint.TryAuthenticateAndSignIn):

ApplyUserGrantedScopes(principal, request.GetScopes()); // role-narrowed SetScopes

ResolveUserGrantedScopes narrows the requested scopes by the principal’s role family (see the Roles → scopes note above) before they are set; the client’s allowed_scopes still bound what may be requested. The authority.permissions table is never joined by this path. Token validation at resource servers also operates purely on the JWT’s scope claim.

Console role editor path (ConsoleAdminEndpointExtensions.cs:1236):

var permissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, ...);

The Console UI surfaces authority.permissions rows so operators can build roles by clicking checkboxes rather than typing scope strings. The Console writes authority.role_permissions bridge rows on save, but those bridge rows are still not consulted at JWT-issuance time — they are RBAC catalog state, not enforcement state.

Why seed the full StellaOpsScopes.All set into the table? Operator UX. (As of 2026-05, StellaOpsScopes defines ~178 active consts and grows over time; the collapsed S001 baseline carries the historical full-scope seed, with later S0xx migrations topping up newer scopes.) Without it, the Console role editor only offers the 10 legacy demo names (releases:manage, policy:manage, etc. — none of which match a real scope const), so any operator who wants to build a custom role with policy:approve or vuln:investigate has to either type the scope string blind (no validation) or first POST a permissions row via the API. Seeding the table eliminates that friction.

Why is StandardPluginBootstrapper.EnsureAdminRoleAsync still required? Clean installs no longer rely on the demo seed for the real admin. The standard plugin’s defaultAdminBootstrap path creates or repairs the admin user from STELLAOPS_ADMIN_PASS and then runs EnsureAdminRoleAsync to create any missing permission catalog rows for StellaOpsScopes.All, the admin role, and the role assignment. Seed migrations remain the forward-only catalog history for demo/upgrade paths; runtime bootstrap keeps a seed-off install usable.

Idempotency. Seed permission rows are ON CONFLICT (tenant_id, name) DO NOTHING. The IDs are deterministic so re-running on a populated DB is a zero-row-change no-op. Custom permissions an operator creates through POST /api/v1/console/admin/roles get fresh GUIDs and are not affected by replays.

Trivy/Concelier startup convergence. 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

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.

When PostgreSQL is active, Authority runs a startup reconciler after migrations and before provider descriptors load. Platform and Authority use distinct PostgreSQL databases: the source connection is configured with Authority:IdentityProviderImport:PlatformConnectionString (the compose stack wires it to STELLAOPS_POSTGRES_CONNECTION) and the destination remains Authority:Storage:ConnectionString. If the importer-specific connection is unset, it reuses the canonical Platform connection resolver used by Authority’s shared-tenant propagation; it never queries the Platform schema through the Authority database connection. Installations without a Platform database can set Authority:IdentityProviderImport:Enabled=false, which is an explicit logged no-op. An enabled source whose vestigial platform.identity_provider_configs table is absent is also a logged no-op. An enabled but unreachable/misconfigured source fails startup rather than silently declaring the migration complete.

For a present source table, the reconciler holds a read lock while it groups rows by normalized global provider name and writes the destination in a serializable Authority transaction. An already-present Authority name wins; otherwise identical tenant rows collapse to one global row. Divergent type, enablement, description, configuration, unsupported provider type, invalid JSON, or inline credential material aborts the import before any Authority write and reports only provider names, tenant IDs, and divergent field names. Re-running is idempotent. Platform no longer maps or writes the legacy table; the forward-only migration retains it only as an import source. Physical removal still requires proven import completion and an operator-approved snapshot.

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.

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 + JTI persistenceProgram.cs:382 RequireProofKeyForCodeExchange(); TokenPersistenceHandlers.EnsureTokenId; TokenValidationHandlers.FindByTokenIdAsync reads AuthorityTokenDocument and rejects on revocation/replay
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") (Program.cs:360-361) with EnableAuthorizationEndpointPassthrough() (Program.cs:413) — there are no MapAuthorizationEndpoint/MapTokenEndpoint calls in src/Authority (that API does not exist here). The OIDC plugin does not perform nonce binding — by the time the plugin’s VerifyPasswordAsync sees a token, the auth-code-flow caller (Authority gateway acting as RP, or a CLI/agent presenting a token directly) has already satisfied any nonce contract upstream. The plugin’s job is signature/issuer/ audience validation plus the JTI replay cache described above.

LDAP plaintext deprecation. LdapSecretResolver accepts env:NAME and file:/path indirection in any environment but rejects plaintext literal secrets outside Development (throws InvalidOperationException at startup with a sprint reference in the message). Operator runbooks must use env:LDAP_BIND_PASSWORD (Vault-backed) or file:/run/secrets/ldap-bind. See SPRINT_20260430_014 and docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md § H2.

8.2 Resource-server bypass networks — what a trusted CIDR may and may not grant (W3-B, Sprint 20260712_008)

Authority:ResourceServer:BypassNetworks (bound by every service that calls AddStellaOpsResourceServerAuthentication) lets a request from a listed CIDR proceed without an Authorization header. It is scoped by Authority:ResourceServer:BypassMode (StellaOps.Auth.ServerIntegration/StellaOpsBypassMode.cs).

The trap it was built on. The Router gateway strips Authorization before proxying (IdentityHeaderPolicyMiddleware.cs"Authorization" is in ReservedHeaders) and re-asserts the caller in an HMAC-signed identity envelope, which AddStellaOpsResourceServerAuthentication bridges back into an authenticated principal (ServiceCollectionExtensions.cs, OnMessageReceived). Gateway-proxied user traffic therefore arrives header-less from the compose subnet — the exact shape the bypass was written to trust. Before this sprint a listed CIDR force-satisfied every requirement (StellaOpsScopeAuthorizationHandler.cs: scopes, the tenant allow-list, and the fresh-auth / dual-control gates), so any authenticated user whose token merely lacked a scope was handed that scope by their network position. The bypass was an authorization bypass, not the authentication convenience it was documented as.

Contract now (BypassMode, default Strict). Network position substitutes for a missing identity; it never adds authority on top of an asserted one.

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 (audited per-endpoint) and the request is short-circuited before the handler runs. Note: the canonical filter passes through to the handler without setting the resolved tenant, and the handler’s null-check produces the 400 along with the endpoint-specific audit event.
  2. Header present + bearer’s allowed-tenants list non-empty + header value not in the list → 403 forbidden.
  3. Header present + bearer’s stellaops:tenant claim present + values do not match (case-insensitive) → 403 forbidden, unless the caller is a global admin. The global-admin check (TenantHeaderFilter.cs:65-70ConsoleGlobalAdminResolver.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/users/*ConsoleAdminEndpointExtensions.MapConsoleAdminEndpoints
/console/admin/roles/*same
/console/admin/clients/*same
/console/admin/audit/*same
/console/admin/branding/*ConsoleBrandingEndpointExtensions.MapConsoleBrandingEndpoints

/console/admin/identity-providers/* is intentionally absent from this table: it is process-global, has its own global-admin filter, and never derives a tenant from a request header or token claim.

The /internal/* bootstrap automation tier authenticates with the X-StellaOps-Bootstrap-Key header (not this tenant header, and not an authority.admin scope — that scope does not exist) and is NOT covered by this header. The /.well-known/*, /connect/*, /jwks*, /health, and /ready surfaces are explicitly tenant-agnostic.

The architecture-conformance pack (src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/AuthorityTenantHeaderConsistencyTests.cs) drives an HTTP probe against each tenant-scoped path without the header and asserts the response is in {401, 400, 403, 404} — never 2xx. The endpoint list is locked at ≥4 via a self-test so accidental coverage shrinkage trips a build failure.

The OpenAPI spec for Authority documents X-StellaOps-TenantId as a required parameter on all tenant-scoped operations (src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml).

Resource-server global-admin override

Downstream services that use StellaOpsTenantResolver remain claim-first for ordinary principals: a non-admin bearer cannot switch tenants by sending a tenant header. Sprint 20260530.033 adds a narrow Console-admin exception for principals whose token carries stellaops:admin=true: the resolver accepts the canonical X-StellaOps-TenantId header and reports TenantSource.CanonicalHeader. Legacy tenant headers are still non-authoritative. This is the backend contract for the Console admin tenant switcher and follows docs/architecture/decisions/ADR-023-console-admin-tenant-switch-strategy.md.

GET /console/tenants uses the same global-admin signal for Console tenant switching. Ordinary principals receive only their assigned tenant catalogue. Global administrators receive the full platform shared.tenants catalogue; 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.

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

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

Console admin tier (/console/admin/*, scope-gated):

GET  /console/admin/tenants            POST /console/admin/tenants
POST /console/admin/tenants/{id}/suspend|resume
GET  /console/admin/users              POST /console/admin/users
POST /console/admin/users/{id}/disable|enable
GET  /console/admin/roles              POST /console/admin/roles
POST /console/admin/roles/{id}/preview-impact
GET  /console/admin/clients            POST /console/admin/clients
POST /console/admin/clients/{id}/rotate
GET  /console/admin/tokens             POST /console/admin/tokens/revoke
GET  /console/admin/audit
GET  /console/admin/branding           POST /console/admin/branding/preview

GET /console/admin/audit is a deprecated compatibility proxy for Timeline’s canonical GET /api/v1/audit/events?modules=authority read. It binds the query to the authenticated Authority tenant, forwards decoded filters, and signs the Authority-to-Timeline hop with a short-lived tenant-bound service identity envelope carrying only timeline:read. The hop fails closed before HTTP when the shared identity-envelope key is absent. Timeline dependency failures are reported as sealed HTTP 502 ProblemDetails with reason=timeline_audit_unavailable; raw upstream exception, database, and credential detail is never returned to the Console.

POST /console/admin/clients/{id}/rotate mints a new 256-bit client secret, persists its hash (plaintext never stored), and returns the secret once. To let statically-configured service clients roll over without an outage it opens a dual-secret grace window: the superseded secret keeps authenticating until a recorded expiry (default 7 days, max 30; graceHours: 0 = invalidate immediately), honored by ValidateClientCredentialsHandler via VerifySecretWithGrace. The window state lives in the client document’s Properties (previousSecretHash / previousSecretExpiresAt) — JSONB, no new schema. Rotation is confidential-clients-only (400 client_secret_not_supported otherwise). Distribution stays operator-driven (Authority never pushes secrets into customer-controlled deploy config/Vault); the full rollout model and the owed live rollover proof are in operations/client-secret-rotation.md.

Tenant creation keeps the pre-flight slug lookup for operator feedback, but the database unique constraint remains authoritative under concurrency. If a concurrent POST /console/admin/tenants wins after the lookup, Authority translates PostgreSQL 23505 into the same 409 tenant_already_exists response instead of surfacing a server error.

Health & metadata (anonymous, tenant-agnostic): GET /health, GET /ready, GET /jwks, GET /.well-known/openid-configuration, GET /.well-known/openapi. (There is no /admin/metrics, /admin/healthz, or /admin/readyz; metrics are exported through the shared platform telemetry pipeline configured in AuthorityTelemetryConfiguration, not a dedicated /admin/metrics route.)

Declared client audiences flow through to the issued JWT aud claim and the token request’s resource indicators. Authority relies on this metadata to enforce DPoP nonce challenges for signer, attestor, and other high-value services without requiring clients to repeat the audience parameter on every request.

10.1 Tenant-create auto-onboarding & first-party client flag (Sprint F3)

When a new tenant is created via POST /console/admin/tenants, every client flagged as first-party is automatically extended to include the new tenant in its properties.tenants allow-list, so the operator never needs to run the manual UPDATE authority.clients SET properties[Tenants] = … SQL the WS-7 onboarding flow was previously plagued by. The mechanism has three layers:

  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 (lines 335/346; 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 (line 351) 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 Pattern-B service clients such as 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).
  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.

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.

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/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 (line 355; it folded in the pre-1.0 S033_client_tenants_link_table.sql):

CREATE TABLE authority.client_tenants (
    client_id   TEXT NOT NULL REFERENCES authority.clients(client_id) ON DELETE CASCADE,
    tenant_id   TEXT NOT NULL CHECK (tenant_id <> ''),
    granted_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    granted_by  TEXT,
    PRIMARY KEY (client_id, tenant_id)
);
CREATE INDEX idx_client_tenants_tenant ON authority.client_tenants(tenant_id);

The migration backfills one row per (client_id, tenant) pair derived from every existing properties.tenants string, tagged granted_by = 'migration:S033'.

Why the link table is the structural fix to the D1.3 class-of-bug:

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

Illustrative, not the live bootstrap schema. The clients: block below is a conceptual reference. The actual first-party client bootstrap file (devops/etc/authority/plugins/standard.yaml, § 1) uses a different shape — bootstrapClients: with allowedGrantTypes/allowedScopes/allowedAudiences string fields, confidential, requirePkce, etc. Use § 1 for the real schema.

authority:
  issuer: "https://authority.internal"
  signing:
    enabled: true
    activeKeyId: "authority-signing-2025"
    keyPath: "../certificates/authority-signing-2025.pem"
    algorithm: "ES256"
    keySource: "file"
  security:
    rateLimiting:
      token:
        enabled: true
        permitLimit: 30
        window: "00:01:00"
        queueLimit: 0
      authorize:
        enabled: true
        permitLimit: 60
        window: "00:01:00"
        queueLimit: 10
      internal:
        enabled: false
        permitLimit: 5
        window: "00:01:00"
        queueLimit: 0
    senderConstraints:
      dpop:
        enabled: true
        allowedAlgorithms: [ "ES256", "ES384" ]
        proofLifetime: "00:02:00"
        allowedClockSkew: "00:00:30"
        replayWindow: "00:05:00"
        nonce:
          enabled: true
          ttl: "00:10:00"
          maxIssuancePerMinute: 120
          store: "valkey"  # legacy "redis" alias accepted; uses redis:// protocol
          redisConnectionString: "redis://authority-valkey:6379?ssl=false"
          requiredAudiences:
            - "signer"
            - "attestor"
      mtls:
        enabled: true
        requireChainValidation: true
        rotationGrace: "00:15:00"
        enforceForAudiences:
          - "signer"
        allowedSanTypes:
          - "dns"
          - "uri"
        allowedCertificateAuthorities:
          - "/etc/ssl/mtls/clients-ca.pem"
  clients:
    - clientId: scanner-web
      grantTypes: [ "client_credentials" ]
      audiences: [ "scanner" ]
      auth: { type: "private_key_jwt", jwkFile: "/secrets/scanner-web.jwk" }
      senderConstraint: "dpop"
      scopes: [ "scanner:scan", "scanner:export", "scanner:read" ]
    - clientId: signed-sbom-producer
      grantTypes: [ "client_credentials" ]
      audiences: [ "api://scanner" ]
      auth: { type: "client_secret", secretFile: "/secrets/signed-sbom-producer.secret" }
      senderConstraint: "dpop"
      scopes: [ "scanner.signed-sbom-material.write" ]
    - clientId: signer
      grantTypes: [ "client_credentials" ]
      audiences: [ "signer" ]
      auth: { type: "mtls" }
      senderConstraint: "mtls"
      scopes: [ "signer:sign" ]
    - clientId: notify-web-dev
      grantTypes: [ "client_credentials" ]
      audiences: [ "notify.dev" ]
      auth: { type: "client_secret", secretFile: "/secrets/notify-web-dev.secret" }
      senderConstraint: "dpop"
      scopes: [ "notify.viewer", "notify.operator", "notify.admin" ]
    - clientId: notify-web
      grantTypes: [ "client_credentials" ]
      audiences: [ "notify" ]
      auth: { type: "client_secret", secretFile: "/secrets/notify-web.secret" }
      senderConstraint: "dpop"
      scopes: [ "notify.viewer", "notify.operator" ]

15) Testing matrix


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 clientmTLS or private_key_jwt with pinned JWK; client registration & 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
Downgrade to bearerStrip DPoPResource servers require DPoP/mTLS based on aud; reject bearer without cnf

17) Deployment & HA


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)",
  "stellaops:idp": "standard",
  "cnf": { "jkt": "KcVb2V...base64url..." }
}

DPoP proof header fields (for POST /sign/dsse)

{
  "htu": "https://signer.internal/sign/dsse",
  "htm": "POST",
  "iat": 1760668620,
  "jti": "4b1c9b3c-8a95-4c58-8a92-9c6cfb4a6a0b"
}

Signer validates that hash(JWK) in the proof matches cnf.jkt in the token.


20) Rollout plan

  1. MVP: Client Credentials (private_key_jwt + DPoP), JWKS, short OpToks, per-audience scopes.
  2. Add: mTLS-bound tokens for Signer/Attestor; device code for CLI; optional introspection.
  3. Hardening: DPoP nonce support; full audit pipeline; HA tuning.
  4. UX: Tenant/installation admin UI; role→scope editors; client bootstrap wizards.

21) Identity domain schema ownership

ADR: No-merge decision (Sprint 216, 2026-03-04)

Authority and IssuerDirectory share the same PostgreSQL instance but use separate schemas and separate DbContext classes. This is a deliberate security decision, not a consolidation oversight.

21.1 AuthorityDbContext (schema: authority)

The most security-critical schema in the system. Owns:

Table/Entity groupSecurity classificationContent
UsersCriticalPassword hashes, MFA state, lockout counters, email verification
SessionsCriticalActive session tokens, refresh tokens, device grants
TokensCriticalIssued OpTok metadata, revocation records, jti replay cache
Roles & PermissionsHighRole-to-scope mappings, audience bindings
ClientsHighClient registrations, JWK material references, grant type configs
TenantsHighTenant/installation registry, cross-tenant isolation boundaries
MFACriticalTOTP secrets, recovery codes, WebAuthn credentials
AuditHighAuthentication event log, admin change trail

Compiled models: AuthorityDbContext uses EF Core compiled models (generated by Sprint 219). The <Compile Remove> directive for EfCore/CompiledModels/AuthorityDbContextAssemblyAttributes.cs lives in src/Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj.

21.2 IssuerDirectoryDbContext (default schema: issuer)

Manages trusted VEX/CSAF publisher metadata. Owns:

Table/Entity 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 from src/IssuerDirectory/ by Sprint 216).

Non-testing IssuerDirectory web runtime now requires PostgreSQL persistence; in-memory repositories remain an explicit local/test harness path and cannot be registered through the default infrastructure extension. Anonymous IssuerDirectory mode is likewise a local/test harness path: if IssuerDirectory:Authority:Enabled=false, production-like startup fails before serving unsigned issuer metadata.

IssuerDirectory supports a configured PostgreSQL schema through IssuerDirectory:Persistence:SchemaName and the legacy IssuerDirectory:Postgres:Schema; when unset, the default remains issuer. During the pre-release alpha window, Sprint 20260425-010 accepted rewriting startup migration 001_initial_schema.sql to use unqualified object names under the shared migration runner’s configured search_path. This makes fresh databases converge into the configured schema and keeps repository SQL, startup migrations, and tests aligned. Existing disposable alpha databases that already applied the old issuer.* migration must be reset or manually reconciled because the migration checksum changed.

21.3 No-merge security rationale

Decision: Schemas remain permanently separate. No cross-schema DB merge.

Rationale:

21.4 IssuerDirectory domain ownership

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