ADR-027: Account-bound, resource-scoped deployment share-grant delegation

Status: Accepted Date: 2026-06-08 Sprint: SPRINT_20260608_001_ReleaseOrchestrator_deployment_share_grants.md Related ADRs: ADR-004 (forward-only migrations), ADR-005 (single-operator multi-environment tenancy), ADR-023 (Console admin tenant-switch / identity envelope), ADR-025 (operator-signed governance decisions)

Read this before changing how a deployment-monitor share link works, before adding any delegation/elevation path to ReleaseOrchestrator, or before introducing per-resource grants anywhere in the platform.

Context

An operator watching a live deployment on the Console deployment monitor (/releases/deployments/{id}) frequently needs to hand a colleague a link — “watch this”, or “you take over, pause it if the canary spikes”. Two naive solutions are both wrong for a sovereign, on-prem, auditable release control plane:

  1. Anonymous bearer link (?token=<secret>): the link itself is the credential. Anyone who sees it (chat history, browser history, a proxy log, a shoulder-surf) holds the capability. It cannot be attributed to a real person, it bypasses OIDC entirely, and it amplifies whatever the issuer holds to an anonymous audience. This is exactly the class of credential leak the platform exists to prevent.
  2. Mint a new global scope / a new token: handing someone orch:operate or release:publish (even briefly) is tenant-wide, not resource-scoped — it authorizes pausing/deploying every deployment and release in the tenant, not just the one being shared. It also requires touching the Authority token-issuance and identity-envelope path, which is shared by 40+ services and is the wrong blast radius for a Console convenience feature.

ReleaseOrchestrator already has the right primitives: the operate endpoints (/deployments/{id}/pause|resume|cancel|rollback, target retry) gate on orch:operate (with release:publish for rollback), and the initiate endpoints (/releases/{id}/deploy|promote|rollback) gate on release:publish (ReleaseOrchestratorPolicies.ReleaseApprove). Production promotions already enforce separation-of-duties (a requester cannot be the sole approver). What was missing was a way to delegate a single deployment’s operate/deploy capability to a specific authenticated person, time-boxed, revocable, and audited — without a new global scope and without an anonymous credential.

The platform is offline / air-gap-first, on-prem, sovereign-first, with BUSL-1.1 supply-chain-integrity guarantees and per-service auto-migrating PostgreSQL schemas. A delegation model must honour all of that: no cloud-managed services, no new always-on attack surface, deterministic behaviour, durable + idempotent persistence, and full attribution to the real acting human.

Decision

A share link carries an opaque grantId, never a credential. Opening it does nothing privileged. The grant elevates the authenticated subject who accepts it — scoped to one resource, time-boxed, revocable, and fully audited — and is enforced entirely inside ReleaseOrchestrator against the request principal. No new global scope, no Authority/identity-envelope change.

  1. Account-bound, not link-bound. The grantId (opaque dsg_<32 hex>, globally unique) is a selector, not a secret. A recipient must sign in through the existing OIDC flow and then POST /share/{grantId}/accept; only the accepted subject (recorded in deployment_share_grant_acceptances) is ever elevated. An anonymous accept/use returns 401. Knowing the grantId confers nothing.

  2. Two capability levels. A grant is either:

    • View (default) — read the deployment monitor; confers the scope set [release:read]. Activates immediately.
    • Operate & Deploy (elevated) — confers BOTH orch:operate (pause/resume/cancel/rollback/retry this deployment, keyed by deploymentId) AND release:publish (deploy/promote/rollback this release into this same target environment, keyed by releaseId + environmentId). The platform never issues a half-elevated grant.
  3. Resource-scoped enforcement via a native-scope-OR-grant fallback. Each protected endpoint keeps its native scope check unchanged and adds a fallback: the action is authorized when the caller holds the native scope (or is a global admin) OR has an active, accepted Operate & Deploy grant for this exact resource. The grant for deployment D1 never authorizes D2; a deploy-grant for (releaseR, envStage) never authorizes (releaseR, envProd) nor any other release. The native path is never weakened — a holder of the native scope still passes byte-identically. (Implementation note: the per-route elevated RequireAuthorization was moved into the handler so an OR-fallback is reachable; ASP.NET authorization middleware runs before the handler and cannot express “scope OR grant”.)

  4. No amplification. At create time the issuer must currently hold the capability being delegated for a resource they controlView requires release:read; Operate & Deploy requires orch:operate AND release:publish (or global admin). Otherwise create returns 403. You cannot delegate more than you have.

  5. Same-tenant only. Every grant read/write is tenant-scoped (PK (tenant_id, grant_id)); a recipient in another tenant gets 404 on get/accept. A SpecificSubjects audience further binds the grant to an explicit subject list (JSONB containment); an outsider’s accept returns 403.

  6. Production 4-eyes. An elevated grant whose target environment type is Production is created status=PendingApproval and behaves as View-only (inert) — even after the recipient accepts — until a second authorized person approves it via POST /share/{grantId}/approve. The approver must be a global admin OR hold the full delegated capability (orch:operate AND release:publish) and must differ from the issuer (server-enforced). Non-prod elevated grants and all View grants activate immediately. Production classification is fail-safe: it resolves the environment via IEnvironmentService, and if the environment is unseeded or the lookup throws, a prod-like name (contains prod, excluding nonprod/non-prod) is treated as Production so 4-eyes cannot be bypassed.

  7. Time-boxed, revocable, auto-revoked. Default lifetime 24h, capped at 30 days; expiry is honoured at authorization time. Revoke (DELETE /share/{grantId}) is effective immediately. A grant auto-revokes when its deployment reaches a terminal state (cancel/rollback via the HTTP path, broadened to every terminal transition), a periodic DeploymentShareGrantExpirySweeper marks expired grants Expired, and — defence in depth — authorization is denied outright when the target deployment is already terminal (covers engine-driven succeeded/failed completion that happens outside the HTTP endpoints). An optional maxUses budget is enforced atomically (single conditional UPDATE ... WHERE use_count < max_uses RETURNING) so concurrent uses cannot overrun it.

  8. Attributable. Every privileged action taken via a grant emits a release.share.used audit event with the real acting subject + grantId + capability + route + resource ids, and increments the grant’s use count. The native-scope path emits no share.used event (it is not grant-backed). Lifecycle actions emit release.share.{created,toggled,accepted,approved,revoked}.

The seven security invariants (each is a test assertion)

  1. No anonymous elevation — unauthenticated accept/use → 401; capability applies only after sign-in and acceptance.
  2. Resource scoping — a grant for D1 never authorizes D2; a deploy-grant for (R, Stage) never authorizes (R, Prod) nor a different release.
  3. No amplification — the issuer must currently hold the capability being delegated, for a resource they control; else create → 403.
  4. Same-tenant only — the recipient must be in the grant tenant.
  5. Time-boxed + revocable — expiry honoured; revoke effective immediately; auto-revoke on terminal deployment state.
  6. Production 4-eyes — an elevated Prod grant is inert (View-only) until approved; the approver must differ from the issuer (server-enforced) and hold the full delegated capability.
  7. Attributable — every grant-backed privileged action is audited with the real acting subject + grantId + capability.

Coverage: 7/7 are proven end-to-end by ShareGrantEnforcementEndpointsTests (driving the real operate/initiate endpoints), plus DeploymentShareGrantServiceTests (service logic), ShareGrantEndpointsTests (HTTP surface), and DeploymentShareGrantRepositoryPersistenceTests (Testcontainers Postgres exercising the real SQL + migration). See the sprint for counts.

Alternatives rejected

Consequences