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:
MechaCat02
2026-07-11 14:31:15 +02:00
parent 4b4b3148b7
commit c06a9e801e
8 changed files with 350 additions and 34 deletions

View File

@@ -4,13 +4,16 @@
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
//! opt in per environment"). An unlisted or `confirm = false` env applies
//! freely. The CLI gates client-side (fast fail) AND the SERVER re-derives the
//! policy from the request and enforces it — a confirm-required apply is refused
//! (409) unless approved, and an approved override additionally requires ADMIN on
//! every declared node (a step up from editor write caps) and is audited. This
//! closes the patched/older-CLI bypass (a client that still declares the project
//! but skips the local check); a request that omits the policy is inherently
//! ungated (the policy is applier-supplied, not persisted server state).
//! freely. The CLI gates client-side (fast fail) AND the SERVER enforces it — a
//! confirm-required apply is refused (409) unless approved, and an approved
//! override additionally requires ADMIN on every declared node (a step up from
//! editor write caps) and is audited.
//!
//! §3 M3 HERMETIC gate: the policy is PERSISTED server-side (`project_environments`,
//! 0067) on every apply, and the gate evaluates against `persisted declared` —
//! so a request that OMITS the policy (or flips a gate to `false`) can no longer
//! bypass a gate a prior apply established. This closes both the patched/older-CLI
//! bypass AND the hand-crafted-request bypass.
use std::fs;
use std::path::Path;
@@ -337,3 +340,129 @@ fn approving_a_gated_apply_requires_admin() {
"the approval denial should be an authz error:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn persisted_policy_gates_a_request_that_omits_it() {
// §3 M3 HERMETIC: once an apply persists a `confirm = true` env, a LATER raw
// request that OMITS the policy entirely (empty `environments`) is STILL
// gated — the server enforces against the persisted policy, not just the
// wire. This is the hand-crafted-request bypass the earlier CLI-only gate
// could not close.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("eah-grp");
let project = common::unique_slug("eah-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let http = reqwest::blocking::Client::new();
let apply = |environments: serde_json::Value, approved: serde_json::Value| {
http.post(format!("{}/api/v1/admin/tree/apply", env.url))
.bearer_auth(&env.token)
.json(&serde_json::json!({
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
"project": { "slug": project, "environments": environments },
"env": "production",
"approved_envs": approved,
"prune": false,
}))
.send()
.expect("tree apply")
};
// First apply DECLARES + APPROVES the gate → persists `production=true`.
let seed = apply(
serde_json::json!({ "production": { "confirm": true } }),
serde_json::json!(["production"]),
);
assert!(
seed.status().is_success(),
"the seeding apply (declared + approved) must succeed (got {}: {})",
seed.status(),
seed.text().unwrap_or_default()
);
// Now a request that OMITS the policy and does NOT approve → still 409,
// because the persisted policy still gates `production`.
let omit = apply(serde_json::json!({}), serde_json::json!([]));
assert_eq!(
omit.status().as_u16(),
409,
"a persisted gate must hold even when the request omits the policy (got {}: {})",
omit.status(),
omit.text().unwrap_or_default()
);
// Approving it clears the persisted gate (admin token holds GroupAdmin).
let ok = apply(serde_json::json!({}), serde_json::json!(["production"]));
assert!(
ok.status().is_success(),
"approving the persisted gate must let it through (got {}: {})",
ok.status(),
ok.text().unwrap_or_default()
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn flipping_a_gate_to_false_in_the_same_request_still_gates() {
// §3 M3 HERMETIC (monotonic rule): the gate evaluates against
// `persisted declared`, so declaring `confirm = false` for an env a prior
// apply persisted as `true` does NOT un-gate it in the same request — the
// ungating apply must itself pass the gate. This closes the flip-to-false
// bypass.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("eaf-grp");
let project = common::unique_slug("eaf-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let http = reqwest::blocking::Client::new();
let apply = |environments: serde_json::Value, approved: serde_json::Value| {
http.post(format!("{}/api/v1/admin/tree/apply", env.url))
.bearer_auth(&env.token)
.json(&serde_json::json!({
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
"project": { "slug": project, "environments": environments },
"env": "production",
"approved_envs": approved,
"prune": false,
}))
.send()
.expect("tree apply")
};
// Persist `production=true`.
assert!(apply(
serde_json::json!({ "production": { "confirm": true } }),
serde_json::json!(["production"]),
)
.status()
.is_success());
// Same request flips it to false but does NOT approve → still 409 (the
// persisted `true` wins under the union rule).
let flip = apply(
serde_json::json!({ "production": { "confirm": false } }),
serde_json::json!([]),
);
assert_eq!(
flip.status().as_u16(),
409,
"flipping a persisted gate to false must not un-gate it in the same request (got {}: {})",
flip.status(),
flip.text().unwrap_or_default()
);
}