feat(hierarchies): per-env approval-policy gating (M5)
A project's root manifest can mark environments confirm-required; applying to
one needs an explicit `pic apply --dir --env <e> --approve <e>` (a blanket
`--yes` does NOT cover it), the act is admin-gated and audited, and the policy
folds into the bound-plan token. The last unbuilt piece of the project-tool
track (§4.2/§6).
- manifest: `[project]` block → `ManifestProject { environments[{name,confirm}] }`,
valid only on the tree's ROOT manifest (rejected elsewhere by build_tree).
- discover: build_tree emits `project` into the bundle from the root manifest.
- apply_service: `TreeBundle.project` + `ProjectPolicy`; policy folds into the
state_token (a policy edit between plan and apply trips StateMoved); plan
surfaces `approvals_required`; new `ApplyError::ApprovalRequired` → 409.
- apply_api: `enforce_env_approval` (after authz_tree) — refuses a gated env
not in `approved_envs`, and on approval requires admin (AppAdmin/GroupAdmin)
on every declared node + audits. The server re-derives the policy from the
bundle (CLI check is convenience; server is authoritative).
- CLI: `--approve <env>` (repeatable); `resolve_approvals` refuses a gated env
non-interactively, prompts on a TTY (retype the env name); plan renders gated
envs. Single-node `apply --file --env <e>` REFUSES a confirm-required env
(can't carry the admin-gated approval) and directs to `--dir` — closing the
bypass found in review rather than silently skipping the gate.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
lives in the manifest, like takeover/blast-radius).
- doc §11 Phase 5 + §12: approval gating shipped; gate is a `--dir` feature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
163
crates/picloud-cli/tests/approval.rs
Normal file
163
crates/picloud-cli/tests/approval.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
|
||||
//! * a root manifest `[project]` block marks an environment confirm-required,
|
||||
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
|
||||
//! does not cover it) — refused non-interactively at the CLI,
|
||||
//! * `--approve <env>` (as an admin) lets it through,
|
||||
//! * a non-gated environment applies with plain `--yes`,
|
||||
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
|
||||
//! editor with `--approve` is refused server-side (403).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-app project dir whose root manifest declares a `[project]` policy:
|
||||
/// `production` is confirm-required, `staging` is not.
|
||||
fn project_dir(app: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
|
||||
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
|
||||
[[project.environments]]\nname = \"staging\"\nconfirm = false\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// `--env <e>` requires the overlay file to exist; empty overlays keep the
|
||||
// base slug (same app across envs — we're testing the gate, not env routing).
|
||||
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
|
||||
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn confirm_required_env_needs_explicit_approval() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr-g");
|
||||
let app = common::unique_slug("appr-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = project_dir(&app);
|
||||
|
||||
// --- production is confirm-required: --yes alone is refused. ---
|
||||
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"production apply without --approve must be refused"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("approve"),
|
||||
"refusal should mention --approve:\n{err}"
|
||||
);
|
||||
|
||||
// --- with --approve production, it applies. ---
|
||||
let ok = apply(
|
||||
&env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
assert!(
|
||||
ok.status.success(),
|
||||
"approved production apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok.stderr)
|
||||
);
|
||||
|
||||
// --- staging is NOT gated: plain --yes applies. ---
|
||||
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
|
||||
assert!(
|
||||
ok2.status.success(),
|
||||
"non-gated staging apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok2.stderr)
|
||||
);
|
||||
|
||||
// --- single-node `apply --file` to a gated env is refused (no silent
|
||||
// bypass): the admin-gated approval is a `--dir` feature. ---
|
||||
let single = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.args(["--env", "production", "--yes"])
|
||||
.output()
|
||||
.expect("apply --file");
|
||||
assert!(
|
||||
!single.status.success(),
|
||||
"single-node apply to a confirm-required env must be refused"
|
||||
);
|
||||
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
|
||||
assert!(
|
||||
serr.contains("confirm-required") || serr.contains("--dir"),
|
||||
"single-node refusal should point at --dir:\n{serr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn approving_a_gated_apply_requires_admin() {
|
||||
// §4.2: approving a confirm-required env is admin-gated — a second gate on
|
||||
// top of the editor-level write caps an ordinary apply needs. An editor who
|
||||
// CAN write the app still cannot approve a gated apply.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr2-g");
|
||||
let app = common::unique_slug("appr2-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A member with `editor` (write) on the app — enough for an ordinary apply,
|
||||
// not enough to approve a gated environment.
|
||||
let m = member::member_user(fx, &common::unique_username("appr"));
|
||||
member::grant_membership(fx, &app, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
let dir = project_dir(&app);
|
||||
let out = apply(
|
||||
&member_env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
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"),
|
||||
"approval denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod common;
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod approval;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
|
||||
Reference in New Issue
Block a user