The per-env approval gate (`[project.environments]`, `confirm = true`) was
client-side only — the policy was `#[serde(skip_serializing)]`, so a patched or
older CLI applying to a confirm-required env bypassed it entirely. This makes
the gate server-enforced, admin-gated, and audited (the deferred §4.2 piece).
- Wire: `ProjectDecl` gains `environments` (the CLI stops skip_serializing it and
sends the selected `env` + the `--approve` set on the apply request). The
server RE-DERIVES the gate from the request (`env_gate_check`): a
confirm-required env not in `approved_envs` → 409 `ApprovalRequired`.
- Admin gate: an approved override additionally requires `AppAdmin`/`GroupAdmin`
on EVERY declared node — a step up from the editor write caps an ordinary
apply needs (reusing the admin caps per §4.2) — and emits a `tracing::info!`
audit line. Enforced on all three handlers (single app, single group, tree);
the check runs before any tx, so a denial (403) precedes any mutation.
- Token: the env policy folds into the TREE bound-plan token (a policy edit
between plan and apply trips StateMoved). The single-node token is left
unchanged (a bare live-state hash), so plan/apply still match without threading
a project into it.
- CLI: single-node `apply`/`plan` now resolve the GOVERNING project (own block
else nearest ancestor's) so a leaf apply carries the root's policy; `plan`
surfaces `approvals_required`. Client-side `require_env_approval` fast-fail
kept as UX.
Adapts the earlier feat/ownership-and-approval M5 (654e387) to main's DTOs; main
addresses tree group nodes by slug only, so the admin loop mirrors authz_tree
(app: resolve_app_id fail-closed; group: get_by_slug, skip to-create).
Threat-model note (honest scope, in the doc + code comments): this closes the
patched/older-CLI bypass and admin-gates + audits the override, but is NOT a
hermetic boundary against a hand-crafted request that OMITS the policy — the
policy is applier-supplied, not persisted server state. Full closure (persist a
`project_environments` source of truth + a server-determined env) is deferred.
Review (adversarial pass): fixed a MEDIUM (single-node `plan` didn't resolve the
governing project, so a leaf plan under-reported the gate `apply` enforces) and a
LOW (duplicate clippy attr); corrected overstated "can't bypass" wording.
Tests: ProjectDecl gate unit test; env_approval journeys
`server_enforces_the_gate_against_a_non_stock_client` (raw request → 409 without
approval, admin-approved → 200) and `approving_a_gated_apply_requires_admin`
(editor + --approve → 403). 417 lib tests + 33 CLI journeys + workspace clippy
-D warnings + fmt clean. No migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
12 KiB
Rust
340 lines
12 KiB
Rust
//! §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 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).
|
|
|
|
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}"
|
|
);
|
|
}
|