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:
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user