Files
PiCloud/crates/picloud-cli/tests/env_approval.rs
MechaCat02 86448beb06 fix(security): server-authoritative approval gate + auth/session/file hardening
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit.

H1 — the per-env approval gate is now server-authoritative. The governing
project is resolved from the target node's nearest-claimed ancestor
(`governing_env_policy`/`_tree` + `governing_project_id` +
`ProjectRepository::get_environments_by_id`), independent of the client-supplied
`[project]`. Omitting or spoofing the project block can no longer skip a gate the
owning project established; a to-create group resolves its declared parent's
chain so a fresh subtree node inherits the gate. Fails closed on any read error.

H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe
`rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request
task (unauthenticated per-request DoS). Regression test added.

C1 — admin sessions gain an absolute lifetime cap (migration 0070,
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch`
clamps the sliding bump to it, so a continuously-used or stolen-but-warm token
self-expires. Mirrors the data-plane app-user cap.

C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two
that return a raw credential), so a proxy/CDN/browser cache can't retain it.

B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a
valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and
group download paths now set attachment + `X-Content-Type-Options: nosniff` +
a restrictive CSP, closing a group-path stored-XSS gap.

Also folds in the server-side plan-warning plumbing (`plan_warnings`,
`PlanResult::warnings`) and the `app_only_reject` message helper that the CLI
plan-preview change builds on, plus operator security notes (reads-open shared-
topic SSE; the `--env` label is advisory, not a boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:35 +02:00

540 lines
20 KiB
Rust
Raw Permalink 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 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()
);
}