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:
- 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. - Mint a new global scope / a new token: handing someone
orch:operateorrelease: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.
Account-bound, not link-bound. The
grantId(opaquedsg_<32 hex>, globally unique) is a selector, not a secret. A recipient must sign in through the existing OIDC flow and thenPOST /share/{grantId}/accept; only the accepted subject (recorded indeployment_share_grant_acceptances) is ever elevated. An anonymous accept/use returns 401. Knowing thegrantIdconfers nothing.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 bydeploymentId) ANDrelease:publish(deploy/promote/rollback this release into this same target environment, keyed byreleaseId+environmentId). The platform never issues a half-elevated grant.
- View (default) — read the deployment monitor; confers the scope set
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
D1never authorizesD2; 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 elevatedRequireAuthorizationwas moved into the handler so an OR-fallback is reachable; ASP.NET authorization middleware runs before the handler and cannot express “scope OR grant”.)No amplification. At create time the issuer must currently hold the capability being delegated for a resource they control —
Viewrequiresrelease:read;Operate & Deployrequiresorch:operateANDrelease:publish(or global admin). Otherwise create returns 403. You cannot delegate more than you have.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. ASpecificSubjectsaudience further binds the grant to an explicit subject list (JSONB containment); an outsider’s accept returns 403.Production 4-eyes. An elevated grant whose target environment type is Production is created
status=PendingApprovaland behaves as View-only (inert) — even after the recipient accepts — until a second authorized person approves it viaPOST /share/{grantId}/approve. The approver must be a global admin OR hold the full delegated capability (orch:operateANDrelease: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 viaIEnvironmentService, and if the environment is unseeded or the lookup throws, a prod-like name (containsprod, excludingnonprod/non-prod) is treated as Production so 4-eyes cannot be bypassed.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 periodicDeploymentShareGrantExpirySweepermarks expired grantsExpired, 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 optionalmaxUsesbudget is enforced atomically (single conditionalUPDATE ... WHERE use_count < max_uses RETURNING) so concurrent uses cannot overrun it.Attributable. Every privileged action taken via a grant emits a
release.share.usedaudit event with the real acting subject +grantId+ capability + route + resource ids, and increments the grant’s use count. The native-scope path emits noshare.usedevent (it is not grant-backed). Lifecycle actions emitrelease.share.{created,toggled,accepted,approved,revoked}.
The seven security invariants (each is a test assertion)
- No anonymous elevation — unauthenticated accept/use → 401; capability applies only after sign-in and acceptance.
- Resource scoping — a grant for
D1never authorizesD2; a deploy-grant for(R, Stage)never authorizes(R, Prod)nor a different release. - No amplification — the issuer must currently hold the capability being delegated, for a resource they control; else create → 403.
- Same-tenant only — the recipient must be in the grant tenant.
- Time-boxed + revocable — expiry honoured; revoke effective immediately; auto-revoke on terminal deployment state.
- 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.
- 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
- Anonymous bearer link (
?token=<secret>) — rejected: the link is the credential (leak = full capability), no attribution, bypasses OIDC, amplifies to an anonymous audience. Violates invariants #1, #3, #7. - Minting a new token / new global scope for the recipient — rejected: tenant-wide blast radius (authorizes every deployment/release, not the shared one), and forces a change to the shared Authority issuance + identity-envelope path (40+ services). Violates invariant #2; wrong blast radius.
- A new “shared-deployment” scope in the canonical
StellaOpsScopescatalog — rejected: delegation is per resource, not a global capability. A scope cannot encode “deployment D1 only” / “release R into env Stage only”. Resource scoping must live in ReleaseOrchestrator data, not in a coarse scope string. - Reusing the promotion approval-policy / SoD machinery for delegation — rejected: that machinery gates promotions, not arbitrary operate actions, and is environment-rule-based, not per-person-per-resource. The share grant is a distinct concept (delegate to a named person for a named deployment) and gets its own tables.
- DSSE-signing the grant (per ADR-025) — out of scope for v1: a share grant is an authorization artifact, not a recorded governance decision; the audit hash-chain already provides tamper-evidence for
share.*events. Operator-signed approvals (ADR-025) remain the path if a signed delegation is later required. - Driving terminal auto-revoke from the deployment engine/state layer — rejected as the primary mechanism:
DeploymentShareGrantServicelives in the WebApi app, which already depends on the Deployment library, so an engine→service call would invert the dependency graph. Instead the invariant’s intent is met with three cross-path mechanisms: broadened HTTP terminal auto-revoke, a periodic expiry sweep, and a defence-in-depth deny when the deployment is terminal.
Consequences
- New persistence (forward-only, per ADR-004): three tables in the
release_orchestratorschema —deployment_share_grants,deployment_share_grant_acceptances,deployment_share_grant_approvals— added by the embedded, idempotent migration003_deployment_share_grants.sql(CREATE SCHEMA/TABLE/INDEX IF NOT EXISTS; constraints inline). Auto-applied by the existingAddStartupMigrations(schema=release_orchestrator)on WebApi startup. No manual init step; converges on any fresh DB. - No Authority / identity-envelope / scope-catalog change. Delegation is enforced inside ReleaseOrchestrator against the request principal.
orch:operate/release:publish/release:readare reused verbatim; no new global scope is added toStellaOpsScopes. This is deliberate — see the Authority dossier §4.2 note. - Behaviour change (additive): the operate/initiate endpoints’ per-route elevated
RequireAuthorizationbecame an in-handler native-scope check so the grant fallback is reachable. Semantics for native-scope holders are unchanged; the group still enforcesrelease:read+ tenant. A grant-only operator now passes where a route policy previously blocked them before the handler ran. - New background service:
DeploymentShareGrantExpirySweeper(IHostedService, ~5-min interval) transitions expired Active/PendingApproval grants toExpiredso stored status stops lying; reads additionally derive an effectiveExpiredstatus. Authorization safety never depended on the sweep (it re-checks the clock), but the persisted lifecycle is now consistent. - Determinism / offline: all checks are local DB + clock (via injected
TimeProvider); no connectivity required; the shareurlis relative/origin-agnostic. No cloud-managed services introduced. - Atomicity hardening (from review): the Production capability-raise re-gate is a single statement (
UpdateCapabilityAndStatusAsync) so a grant is never momentarily{OperateDeploy, Active}on Prod without approval;max_usesconsumption is a single conditionalUPDATE; an OperateDeploy grant on a Production environment additionally requires an approval row at authorization time (defence in depth). A latent42P08(untyped NULL resource params) in the find-active SQL was found and fixed by the Postgres test. - Reuse:
ReleaseOrchestratorPoliciesscope policies,IAuditEventEmitter+AuditActions.Release.Share*,IDeploymentCompatibilityStore/IReleaseTruthRepository/IEnvironmentService, the raw-Npgsql repository pattern (PostgresFirstSignalSnapshotRepository), and the FEAuthService(gained a publicisGlobalAdmin()). - FE: a Console share dialog (capability toggle, Prod 4-eyes warning, expiry/audience, copy-link, active-link approve/revoke) and deployment-monitor integration (Share button,
?share=recipient accept-on-load, grant-aware button enablement, non-silent 403 banner). The grant gating is advisory — the backend re-authorizes every action. - Out of scope / follow-on: the Angular
requireAuthGuarddoes not preserve areturnUrlthrough OIDC sign-in, so a logged-out recipient opening a?share=deep link is redirected to/welcomeand not auto-returned (owned by the auth/logged-out stream). Live Playwright e2e of the full share→approve→operate→revoke flow against a running stack is the tracked follow-up (the deterministic in-process enforcement suite is the behavioural proof until then).
