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.
Current adoption boundary (source reviewed 2026-10-07 at
58fd27c7f0232bf40be341a159fe06d6931be314). This ADR retains the accepted transport/attribution design and June W4a evidence. The consolidated Findings and JobEngine hosts do not registerOnBehalfOfActorMiddleware; the shared extension has no current serving-host caller outside the frozen predecessor and its focused test harness. The predecessor proof and consequences below are dated historical implementation evidence, not a current downstream attribution verdict. Recheck both host compositions andsrc/Router/__Libraries/StellaOps.Router.AspNet/OnBehalfOfActorMiddleware.csbefore relying on the proposed actor verification path.
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
- Source review on 2026-10-07 at
58fd27c7f0232bf40be341a159fe06d6931be314: neither the consolidatedsrc/Findings/StellaOps.Findings.WebService/Program.csnorsrc/JobEngine/StellaOps.JobEngine.WebService/Program.csregistersOnBehalfOfActorMiddleware. The June W4a Findings implementation/test evidence belongs to the predecessor composition; it does not establish current consolidated downstream actor attribution. Scheduler is a subdomain of JobEngine, not a separately deployed web service. A relayed operator envelope or Authority Console audit alone does not establish downstream processing. The current owner must evaluate and prove the composed downstream audit path, including its emitted and persisted actor/delegate fields. Actual attribution remains UNVERIFIED; missing this registration alone proves neither actor loss nor a required implementation change. - 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 June W4a transition deferred Scheduler-side processing. Current consolidated downstream actor attribution remains UNVERIFIED until the composed downstream audit path and its emitted/persisted actor fields are proved. 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).
