feat(apply): make the per-env approval gate hermetic (§3 M3 / Track A M1)
The per-env approval policy was applier-supplied — a hand-crafted request
that omitted `project.environments` was ungated, and flipping a gate to
`confirm = false` in the same request un-gated it. Persist the policy
server-side and enforce against `persisted ∪ declared`.
- Migration 0067: `project_environments (project_id, env_name, confirm)`,
CASCADE on the project. Written declaratively (delete-then-insert) inside
`upsert_project_tx`, same tx as the project row + node claim.
- `ProjectRepository::get_environments_by_slug` (read side) +
`ApplyService::effective_env_policy` union the persisted policy with the
request's declared one (confirm if EITHER says so — monotonic).
- `env_gate_check` now evaluates the effective policy; the three handlers load
it before the gate. `plan.approvals_required` is the effective gated set, so
CI sees a persisted gate even when the manifest omits it.
- The union rule closes both bypasses: an omitted policy still trips a
persisted gate, and an ungating apply must itself pass the gate (the
declarative replace then takes effect next time) — TOCTOU-safe.
Pinned by env_approval::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates} +
projects_repo::get_environments_by_slug_returns_persisted_policy. Schema golden
reblessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -844,12 +844,19 @@ handlers (single app, single group, tree); the env policy folds into the **tree*
|
||||
between plan and apply trips `StateMoved`); single-node `apply --file` now sends the GOVERNING project (own block
|
||||
else nearest ancestor's), so a leaf apply carries the root's policy; `plan` surfaces `approvals_required`. Pinned by
|
||||
`tests/env_approval.rs::{server_enforces_the_gate_against_a_non_stock_client, approving_a_gated_apply_requires_admin}`.
|
||||
**Threat-model note (honest scope):** this closes the "patched/older CLI that still declares the project but skips
|
||||
the local check" bypass, and admin-gates + audits the override. It is NOT a hermetic authorization boundary against
|
||||
a hand-crafted request that OMITS the policy (or the `env` label) — the policy is applier-supplied, not persisted
|
||||
server state, so an omitted policy is inherently ungated. **Full closure (deferred):** persist the env policy
|
||||
server-side (a `project_environments` source of truth) + a server-determined env, so enforcement is independent of
|
||||
what the request declares. **Also deferred:** env-as-app mapping + declarative server-side `@E` config scoping.
|
||||
|
||||
**HERMETIC (shipped — Track A M1, 2026-07-11):** the policy is now PERSISTED server-side — `project_environments`
|
||||
(migration `0067`, `(project_id, env_name, confirm)`, CASCADE) is written declaratively inside every apply's
|
||||
`upsert_project_tx`, and the gate evaluates against the **effective policy = persisted ∪ declared**
|
||||
(`ApplyService::effective_env_policy` unions `ProjectRepository::get_environments_by_slug` with the request's
|
||||
`[project.environments]`). An env is confirm-required if EITHER the stored row OR the request marks it so, which
|
||||
closes both remaining bypasses: a hand-crafted request that OMITS the policy still trips a persisted gate, and
|
||||
flipping a gate to `confirm = false` in the same request does NOT un-gate it (the ungating apply must itself pass
|
||||
the gate; the declarative replace then takes effect for next time — a monotonic, TOCTOU-safe rule). `plan`'s
|
||||
`approvals_required` is likewise the effective set, so CI sees a persisted gate even when the manifest omits it.
|
||||
Pinned by `tests/env_approval.rs::{persisted_policy_gates_a_request_that_omits_it,
|
||||
flipping_a_gate_to_false_in_the_same_request_still_gates}` + `tests/projects_repo.rs::get_environments_by_slug_returns_persisted_policy`.
|
||||
**Still deferred (separate, not a gate hole):** env-as-app mapping + declarative server-side `@E` config scoping.
|
||||
|
||||
**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user