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