ADR-029: Console-internal service-account transport + verified on-behalf-of operator attribution
Status: Accepted Date: 2026-06-11 Sprint: SPRINT_20260608_023_Authority_console_workspace.md (W4a) Related ADRs: ADR-023 (Console admin tenant-switch / identity envelope), ADR-025 (operator-signed governance decisions), ADR-027 (account-bound resource-scoped delegation)
Read this before adding any machine identity that calls a downstream service in an operator’s context, before changing how a BFF/aggregator authenticates to platform services, or before migrating an existing service-account (AdvisoryAI, Scanner→Concelier, Concelier→Notifier, …) to least-privilege auto-managed credentials. This is the canonical pattern the service-account conformance migration rolls out platform-wide.
Context
The Authority Console runs inside Authority. The Router gateway proxies /console/* over HTTP and — unlike the API surfaces — strips the user’s bearer (no PreserveAuthHeaders): Authority authenticates the gateway-signed identity envelope, not a forwarded user token. So the Console workspace aggregator (/console/vuln/*, /console/vex/statements, /console/dashboard, /console/filters), which must fan out to Findings (/v1/vulns, /v1/vex-decisions) and Scheduler (/api/v1/scheduler/policy/runs), has no user token to forward.
Two naive solutions are both wrong for a sovereign, on-prem, auditable control plane:
- Forward a user Bearer. There is none to forward (the gateway stripped it), and reconstructing one would require Authority to mint a user token for itself — a privilege-amplification and impersonation path.
- A broad static service token. Handing the Console a wide, static-tenant machine token (the historical pattern: a hardcoded-hash SQL migration like AdvisoryAI’s
S005, whose own comment admits rotation bricks token issuance) means (a) the token’sstellaops:tenantclaim does not track the operator’s validated tenant, so downstream tenant enforcement is defeated or bypassed via a raw header; (b) the audit trail records the machine, not the human, breaking the supply-chain-integrity promise that the operator, not the machine, is in the evidence chain; and © the credential is unmanaged and unrotatable.
The platform is offline / air-gap-first, on-prem, sovereign-first, BUSL-1.1, with per-service auto-migrating PostgreSQL schemas. The transport + attribution model must honour all of that: no cloud-managed services, least privilege, deterministic behaviour, durable + rotatable secrets via Vault, and full attribution to the real acting human.
Decision
The Console authenticates to downstream services as a least-privilege, auto-managed console-workspace client-credentials service account with a per-tenant minted token, and RELAYS (never re-mints) the gateway-signed operator identity envelope as a cryptographically-verified on-behalf-of sidecar so the audit author is the real operator. The service token remains the authorization principal; the on-behalf-of actor is audit-only and never widens authorization.
Least-privilege service account.
console-workspacerequests exactlyvuln:view scheduler:read policy:run aoc:verifyfor audiencesapi://findings-ledger stellaops api://scheduler. Each scope is verified against the real downstream authorization:/v1/vulns,/v1/vex-decisionsrequirevuln:view;/api/v1/scheduler/policy/runsrequires thescheduler:readpolicy plus apolicy:runEnsureScope;aoc:verifyis the mandatory pairing for advisory/vex read context. Notfindings:read/vex:read/advisory:read.Per-tenant minted token — claim, not header. The token is minted per tenant (
ConsoleWorkspaceTokenProvider, lifted from the Scanner worker’sConcelierLearnTokenHandler) with thetenantform param drivingstellaops:tenant. The tenant comes fromTenantHeaderFilter.GetTenant(httpContext)— already validated against the inbound envelope — not a static tenant and not a trusted raw header. Downstream services enforce the claim, so a cross-tenant request is rejected before any outbound call (theTenantHeaderFilter403s first) and the minted token can never carry another tenant’s claim.Named-tenant allow-list, never wildcard. The service account declares
tenants: "default e2e-lab"(Pattern B: no singulartenantId, so the allow-list infra mints per-tenant tokens from the same client). A tenant off the allow-list fails the mint, and the BFF fails closed to a 502-class — never sample data. Wildcard tenants are explicitly rejected.Auto-managed secret — no hardcoded-hash migration. The client is registered via
standard.yamlbootstrapClientswithclientSecretEnvironmentVariable: CONSOLE_WORKSPACE_CLIENT_SECRET, sourced from Vaultvault://secret/stellaops/console-workspace#secret. Both the issuer (Authority) and the consumer (the Console, inside Authority) read the same env from the same Vault path → restart-to-rotate, both converge. The AdvisoryAIS005hardcoded-hash SQL-migration approach is the anti-pattern and is not used.Attribution = evidence (relay, do not mint). The Console copies the inbound
X-StellaOps-Identity-Envelope/-Signature/-Algorithmverbatim onto the outbound request asX-StellaOps-OnBehalfOf-Envelope/-Signature/-Algorithm, alongside the service-account bearer +X-StellaOps-TenantId. It does not mint a new envelope — the “only the gateway mints” invariant holds; relaying forwards the original gateway HMAC. A newOnBehalfOfActorMiddleware(StellaOps.Router.AspNet) verifies the relay with the sameGatewayIdentityEnvelopeCodec.TryVerify+ signing key the dispatcher uses, checks freshness, drops the actor if its tenant ≠ the token tenant (never widens authZ), and stampsHttpContext.Items["stellaops:onBehalfOf"] = (sub, tenant)without replacing the principal.AuditActionFilterthen prefers the verified operatorsubforActor.Id/Type=user, recordingdetails.onBehalfOf = { delegate = <service sub>, verified = true }.The service token is the authZ principal; on-behalf-of is audit-only. Authorization never widens because of the relayed envelope — the service account’s scopes are the only thing that grants access downstream; the operator identity only changes who the audit event names. The envelope MUST be cryptographically verified before its
subis trusted; a missing/invalid/expired/tenant-mismatched envelope leaves the audit author as the service account (fail-safe, never a fabricated operator).Per-panel user-scope gate. Before each downstream call the operator envelope must carry the user-facing scope:
vuln:viewfor findings/VEX,scheduler:readfor run-health. A missing scope degrades that tile (empty result, zero outbound call) rather than failing the whole workspace — so a partially-scoped operator still gets the panels they’re entitled to.Fail-closed startup.
ConsoleWorkspaceRuntimeConfigurationValidator(wired viaAddRuntimeConfigurationValidators(), copying the EvidenceLocker validator pattern) throws in any non-Development/Testing environment when the downstream base URLs or the service-account secret are missing, or when the resolvedIConsoleWorkspaceServiceis still the hard-codedConsoleWorkspaceSampleService. The sample service is retained for Development /TestingLocalHarnessonly.
Scope of this ADR (W4a) and what is deferred
- The on-behalf-of downstream processing (
OnBehalfOfActorMiddleware) is implemented + tested on Findings only. Scheduler is parallel-WIP (a Doctor-plugin stream ownssrc/JobEngine/StellaOps.Scheduler.WebService); its middleware registration is deferred to the conformance migration. The Console still relays the on-behalf-of envelope on the run-health call (harmless if Scheduler ignores it), and the operator-evidence bar for the dashboard read is met by Authority’s own.Auditedon the Console read endpoints. - Resource-scope read-path enforcement (
serviceIds/repositoryIds/imageNamespaces/environmentIds/maxEnvironmentType) stays write-path ABAC for now. The relayed operator envelope already carries those claims and the dispatcher rehydrates them, so future read-path enforcement is a downstream WHERE-clause addition, not a re-plumbing. W4a lays the forwarding foundation and defers the platform-wide enforcement program. - Evidence tier: operator attribution is verified-on-arrival (HMAC envelope, same codec/key as the platform trust path) + recorded in the audit event — sufficient per the PO decision. It is not independently ledger-sealed in W4a (a separate evidence-tier program).
Consequences
- Positive. The audit trail names the real operator on Console reads and on future service-token writes, with the delegate machine preserved. Downstream tenant enforcement holds because the minted claim — not a trusted header — carries the tenant. The credential is least-privilege, Vault-sourced, and rotatable without a schema change. The pattern is reusable: the conformance migration migrates AdvisoryAI / Scanner→Concelier / Concelier→Notifier / … to it (least-privilege auto-managed scope minting + on-behalf-of operator attribution), dropping hardcoded-hash migrations.
- Negative / trade-offs. Restart-to-rotate (hitless dual-secret rotation is a platform follow-up). The Scheduler-side on-behalf-of processing is deferred, so a Scheduler write triggered via the Console would currently attribute the service account until that registration lands. The relay adds three sidecar headers and one verification hop per downstream call.
Verification
Forcing-function tests (SPRINT_20260608_023):
- Cross-tenant negative. Operator(tenant-A) + header
X-StellaOps-TenantId: tenant-B→ 403 fromTenantHeaderFilterbefore any outbound call (asserted zero workspace invocations). Same-tenant → the minted token’sstellaops:tenantclaim, decoded from the JWT body, equals tenant-A. - Operator-in-evidence. A Console read as
aliceemits an audit event withActor.Id == alice. A.Auditedwrite under aconsole-workspacetoken + a valid on-behalf-ofaliceenvelope persistsActor.Id == aliceanddetails.onBehalfOf.delegate == console-workspace. - Scope-gate negative. An operator lacking
vuln:viewgets a degraded findings tile and no outbound Findings call. - Mint-rejection. A tenant off the allow-list → 502-class, not sample data.
- Fail-closed startup. Non-Development with empty
FindingsBaseUrl/ secret, or the sample service still active → host fails to start. - Envelope tamper / expiry / tenant-mismatch.
OnBehalfOfActorMiddlewaredrops the actor (never widens authZ, never fabricates an operator).
