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:

  1. 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.
  2. 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’s stellaops:tenant claim 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.

  1. Least-privilege service account. console-workspace requests exactly vuln:view scheduler:read policy:run aoc:verify for audiences api://findings-ledger stellaops api://scheduler. Each scope is verified against the real downstream authorization: /v1/vulns,/v1/vex-decisions require vuln:view; /api/v1/scheduler/policy/runs requires the scheduler:read policy plus a policy:run EnsureScope; aoc:verify is the mandatory pairing for advisory/vex read context. Not findings:read / vex:read / advisory:read.

  2. Per-tenant minted token — claim, not header. The token is minted per tenant (ConsoleWorkspaceTokenProvider, lifted from the Scanner worker’s ConcelierLearnTokenHandler) with the tenant form param driving stellaops:tenant. The tenant comes from TenantHeaderFilter.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 (the TenantHeaderFilter 403s first) and the minted token can never carry another tenant’s claim.

  3. Named-tenant allow-list, never wildcard. The service account declares tenants: "default e2e-lab" (Pattern B: no singular tenantId, 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.

  4. Auto-managed secret — no hardcoded-hash migration. The client is registered via standard.yaml bootstrapClients with clientSecretEnvironmentVariable: CONSOLE_WORKSPACE_CLIENT_SECRET, sourced from Vault vault://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 AdvisoryAI S005 hardcoded-hash SQL-migration approach is the anti-pattern and is not used.

  5. Attribution = evidence (relay, do not mint). The Console copies the inbound X-StellaOps-Identity-Envelope / -Signature / -Algorithm verbatim onto the outbound request as X-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 new OnBehalfOfActorMiddleware (StellaOps.Router.AspNet) verifies the relay with the same GatewayIdentityEnvelopeCodec.TryVerify + signing key the dispatcher uses, checks freshness, drops the actor if its tenant ≠ the token tenant (never widens authZ), and stamps HttpContext.Items["stellaops:onBehalfOf"] = (sub, tenant) without replacing the principal. AuditActionFilter then prefers the verified operator sub for Actor.Id/Type=user, recording details.onBehalfOf = { delegate = <service sub>, verified = true }.

  6. 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 sub is trusted; a missing/invalid/expired/tenant-mismatched envelope leaves the audit author as the service account (fail-safe, never a fabricated operator).

  7. Per-panel user-scope gate. Before each downstream call the operator envelope must carry the user-facing scope: vuln:view for findings/VEX, scheduler:read for 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.

  8. Fail-closed startup. ConsoleWorkspaceRuntimeConfigurationValidator (wired via AddRuntimeConfigurationValidators(), 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 resolved IConsoleWorkspaceService is still the hard-coded ConsoleWorkspaceSampleService. The sample service is retained for Development / TestingLocalHarness only.

Scope of this ADR (W4a) and what is deferred

Consequences

Verification

Forcing-function tests (SPRINT_20260608_023):