//! §3 M3 — per-env approval gating (`[project.environments]`). //! //! A `[project.environments]` block marks some envs confirm-required. Applying //! to such an env with `pic apply --env ` is refused unless it is explicitly //! `--approve `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 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; use tempfile::TempDir; use crate::common; use crate::common::cleanup::GroupGuard; use crate::common::member; /// A repo whose `[project]` gates `production` (confirm-required) but not /// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files /// exist so `--env` can load them. fn gated_repo(dir: &Path, project: &str, group: &str) { fs::write( dir.join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{ confirm = true }}\n\ staging = {{ confirm = false }}\n\n\ [group]\nslug = \"{group}\"\nname = \"Env Gated\"\n" ), ) .unwrap(); fs::write( dir.join("picloud.production.toml"), "# production overlay\n", ) .unwrap(); fs::write(dir.join("picloud.staging.toml"), "# staging overlay\n").unwrap(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn confirm_required_env_needs_explicit_approve() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("ea-grp"); let project = common::unique_slug("ea-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let dir = TempDir::new().unwrap(); gated_repo(dir.path(), &project, &group); let manifest = dir.path().join("picloud.toml"); let apply = |args: &[&str]| -> std::process::Output { let mut c = common::pic_as(&env); c.args(["apply", "--file"]).arg(&manifest).args(args); c.output().expect("apply") }; // production is confirm-required → a bare `--env production` is refused, // and the message names `--approve`. (Client-side: never hits the server.) let out = apply(&["--env", "production"]); assert!(!out.status.success(), "production must require approval"); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("approve") && err.contains("production"), "the refusal must point at --approve production:\n{err}" ); // A blanket `--yes` does NOT cover a gated env. assert!( !apply(&["--env", "production", "--yes"]).status.success(), "--yes must not bypass a confirm-required env" ); // `--approve production` lets it through. assert!( apply(&["--env", "production", "--approve", "production"]) .status .success(), "an explicit --approve production must apply" ); // staging is `confirm = false` → applies freely, no approval needed. assert!( apply(&["--env", "staging"]).status.success(), "an un-gated env must apply without --approve" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn an_env_listed_with_an_empty_policy_is_a_hard_error() { // `production = {}` (no `confirm`) must FAIL to load, not silently parse as // un-gated — you can't half-declare a gate. `confirm` is a required field. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eae-grp"); let project = common::unique_slug("eae-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); let dir = TempDir::new().unwrap(); let manifest = dir.path().join("picloud.toml"); fs::write( &manifest, format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{}}\n\n\ [group]\nslug = \"{group}\"\nname = \"Empty Policy\"\n" ), ) .unwrap(); fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest) .args(["--env", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "an env with an empty policy must be a load error, not a silent no-gate" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("confirm"), "the error must name the missing `confirm` field:\n{err}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn leaf_file_apply_honors_the_root_env_gate() { // `pic apply --file ` where the leaf carries no `[project]` still // honors the gate declared in the repo root (found by walking up). let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let base = common::unique_slug("eal-base"); let leaf = common::unique_slug("eal-leaf"); let project = common::unique_slug("eal-proj"); let _b = GroupGuard::new(&env.url, &env.token, &base); let _l = GroupGuard::new(&env.url, &env.token, &leaf); common::pic_as(&env) .args(["groups", "create", &base]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &leaf, "--parent", &base]) .assert() .success(); // Root manifest carries the gating `[project]`; the leaf (in a subdir) has // no `[project]` of its own and is applied on its own with `--file`. let dir = TempDir::new().unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{ confirm = true }}\n\n\ [group]\nslug = \"{base}\"\nname = \"Base\"\n" ), ) .unwrap(); let sub = dir.path().join("sub"); fs::create_dir_all(&sub).unwrap(); let leaf_manifest = sub.join("picloud.toml"); fs::write( &leaf_manifest, format!("[group]\nslug = \"{leaf}\"\nname = \"Leaf\"\n"), ) .unwrap(); fs::write(sub.join("picloud.production.toml"), "# overlay\n").unwrap(); // Without --approve: refused, because the gate is discovered up-tree. let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&leaf_manifest) .args(["--env", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "a leaf --file apply must honor the root's env gate" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("approve") && err.contains("production"), "the refusal must point at --approve production:\n{err}" ); // With --approve production: passes the gate and applies (leaf pre-exists). common::pic_as(&env) .args(["apply", "--file"]) .arg(&leaf_manifest) .args(["--env", "production", "--approve", "production"]) .assert() .success(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn server_enforces_the_gate_against_a_non_stock_client() { // The client-side gate is a convenience; a patched CLI or a raw request can // skip it. The server re-derives the policy from the wire and enforces it: // a raw tree apply to a confirm-required env WITHOUT the env in // `approved_envs` is refused (409), regardless of the client. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eas-grp"); let project = common::unique_slug("eas-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 body = |approved: serde_json::Value| { serde_json::json!({ "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] }, "project": { "slug": project, "environments": { "production": { "confirm": true } } }, "env": "production", "approved_envs": approved, "prune": false, }) }; // No approval on the wire → 409 ApprovalRequired (server-enforced, the CLI's // client-side gate never ran). let resp = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&env.token) .json(&body(serde_json::json!([]))) .send() .expect("tree apply"); assert_eq!( resp.status().as_u16(), 409, "a raw gated apply without approval must be refused server-side (got {}: {})", resp.status(), resp.text().unwrap_or_default() ); // Approved on the wire + an admin token (holds GroupAdmin) → succeeds. let ok = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&env.token) .json(&body(serde_json::json!(["production"]))) .send() .expect("tree apply"); assert!( ok.status().is_success(), "an approved gated apply by an admin must succeed (got {}: {})", ok.status(), ok.text().unwrap_or_default() ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn approving_a_gated_apply_requires_admin() { // §4.2: overriding a gate is ADMIN authority — a step up from the editor // write caps an ordinary apply needs. A group `editor` who CAN write the // group still cannot approve a gated apply of it. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eaa-grp"); let project = common::unique_slug("eaa-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); // A member granted `editor` (write, not admin) on the group. let m = member::member_user(fx, &common::unique_username("eaa")); common::pic_as(&env) .args([ "groups", "members", "add", &group, &m.id, "--role", "editor", ]) .assert() .success(); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); // A gated repo managing that group (with a var, so the editor's write cap is // exercised — proving the admin gate is ABOVE it). Applied by the editor. let dir = TempDir::new().unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{ confirm = true }}\n\n\ [group]\nslug = \"{group}\"\nname = \"Gated\"\n\n\ [vars]\nregion = \"eu\"\n" ), ) .unwrap(); fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap(); let manifest = dir.path().join("picloud.toml"); let out = common::pic_as(&member_env) .args(["apply", "--file"]) .arg(&manifest) .args(["--env", "production", "--approve", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "a non-admin editor must not be able to approve a gated apply" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("forbidden") || err.contains("403"), "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 omitting_the_project_block_entirely_does_not_bypass_a_persisted_gate() { // Audit 2026-07-11 H1: the earlier gate keyed the persisted policy on the // client-supplied `project.slug`, so a request that OMITTED `[project]` // ENTIRELY (`project: null`, not just empty `environments`) yielded an empty // policy and skipped the gate. The fix resolves the GOVERNING project // server-side from the target node's nearest-claimed ancestor, so a request // to a claimed, gated node can no longer apply just by dropping `[project]`. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eao-grp"); let project = common::unique_slug("eao-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(); // Seed: declare + approve the gate → claims the group AND persists // `production=true` on the owning project. let seed = 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": { "production": { "confirm": true } } }, "env": "production", "approved_envs": ["production"], "prune": false, })) .send() .expect("seed apply"); assert!( seed.status().is_success(), "the seeding apply (declared + approved) must succeed (got {}: {})", seed.status(), seed.text().unwrap_or_default() ); // Attack: a raw apply that OMITS the `project` block entirely (no `project` // key on the wire) to the same gated node, WITHOUT approving `production`. // The server resolves the governing project from the node itself, so this is // refused (409) — it does NOT quietly apply. let omit = 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": {} }] }, "env": "production", "approved_envs": [], "prune": false, })) .send() .expect("omit apply"); assert_eq!( omit.status().as_u16(), 409, "omitting [project] must not bypass the persisted gate (got {}: {})", omit.status(), omit.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() ); }