Files
PiCloud/crates/picloud-cli/tests/env_approval.rs
MechaCat02 c06a9e801e 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>
2026-07-11 14:31:15 +02:00

469 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! §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 <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 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 <leaf>` 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 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()
);
}