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:
25
crates/manager-core/migrations/0067_project_environments.sql
Normal file
25
crates/manager-core/migrations/0067_project_environments.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- §3 M3 (hermetic gate): persist the per-env approval policy server-side.
|
||||
--
|
||||
-- Before this table the policy lived ONLY in the apply request
|
||||
-- (`ProjectDecl.environments`), so a hand-crafted request that omitted it was
|
||||
-- ungated — the server had nothing authoritative to check against. This makes
|
||||
-- the gate hermetic: the confirm-required set is stored per project and the
|
||||
-- server enforces against `persisted ∪ declared` (an env is gated if EITHER the
|
||||
-- stored row OR the request says so), so an omitted or flipped-to-false policy
|
||||
-- can no longer bypass a gate a prior apply established.
|
||||
--
|
||||
-- One owner (the project), a single boolean policy per environment name — no
|
||||
-- polymorphic owner and no environment_scope (unlike `vars`/`secrets`, which
|
||||
-- are data the apply targets; this is metadata ABOUT the apply). CASCADE on the
|
||||
-- project mirrors 0066's philosophy: un-claiming/deleting a project drops its
|
||||
-- policy, it does not linger to gate a tree the project no longer owns.
|
||||
|
||||
CREATE TABLE project_environments (
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
-- The environment name as declared in `[project.environments]` (e.g.
|
||||
-- "production"); matched against the apply's `--env`.
|
||||
env_name TEXT NOT NULL,
|
||||
-- TRUE => applying to this env is gated (needs `--approve <env>` + admin).
|
||||
confirm BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (project_id, env_name)
|
||||
);
|
||||
Reference in New Issue
Block a user