diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 61c8aff..b2535e5 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -205,12 +205,22 @@ pub struct ApplyRequest { /// refuses (409) if the app's live state has changed since. #[serde(default)] pub expected_token: Option, - /// §7 ownership: the declaring repo's `[project]` (absent = no claim). + /// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also + /// carries the §3 M3 env approval policy (`project.environments`). #[serde(default)] pub project: Option, /// §7 ownership: reassign a node owned by another project (group-admin). #[serde(default)] pub takeover: bool, + /// §3 M3: the environment this apply targets (the overlay merged). Drives the + /// per-env approval gate when the project marks it `confirm = true`. + #[serde(default)] + pub env: Option, + /// §3 M3: environments the actor explicitly approved (`--approve `). A + /// confirm-required env is refused unless it appears here; `--yes` alone does + /// NOT add it. + #[serde(default)] + pub approved_envs: Vec, } async fn apply_handler( @@ -221,6 +231,15 @@ async fn apply_handler( ) -> Result, ApplyError> { let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?; + // §3 M3: server-authoritative per-env approval gate. If the target env is + // confirm-required and approved, the override additionally requires ADMIN on + // the node (a step up from the editor write caps above) + is audited. + if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? { + require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id)) + .await + .map_err(map_authz)?; + audit_gated_apply(&principal, req.env.as_deref(), 1); + } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, @@ -298,6 +317,17 @@ async fn group_apply_handler( let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune) .await?; + // §3 M3: per-env approval gate — an approved gated apply requires GroupAdmin. + if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? { + require( + svc.authz.as_ref(), + &principal, + Capability::GroupAdmin(group_id), + ) + .await + .map_err(map_authz)?; + audit_gated_apply(&principal, req.env.as_deref(), 1); + } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, @@ -328,6 +358,7 @@ struct TreeApplyRequest { #[serde(default)] expected_token: Option, /// §7 ownership: one `[project]` for the whole tree (the root manifest's). + /// Also carries the §3 M3 env approval policy (`project.environments`). #[serde(default)] project: Option, #[serde(default)] @@ -336,6 +367,12 @@ struct TreeApplyRequest { /// manifest (default `Refuse`; a pre-M2 CLI omits it). #[serde(default)] structure_mode: StructureMode, + /// §3 M3: the environment this apply targets (drives the per-env approval gate). + #[serde(default)] + env: Option, + /// §3 M3: environments the actor explicitly approved (`--approve `). + #[serde(default)] + approved_envs: Vec, } #[derive(Deserialize)] @@ -364,6 +401,13 @@ async fn tree_apply_handler( Json(req): Json, ) -> Result, ApplyError> { authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?; + // §3 M3: server-authoritative per-env approval gate. On an approved gated + // apply, require ADMIN on EVERY declared node (a step up from the editor + // write caps authz_tree checks) + audit. + if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? { + require_admin_on_every_node(&svc, &principal, &req.bundle).await?; + audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len()); + } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, @@ -381,6 +425,79 @@ async fn tree_apply_handler( Ok(Json(report)) } +/// §3 M3: the per-env approval gate, re-derived from the project's policy on the +/// wire (the CLI's client-side check is convenience; THIS is authoritative). +/// Returns `Ok(true)` when the target env is confirm-required AND approved — the +/// caller must then require admin + audit. `Ok(false)` when not gated (no +/// project, no env, or `confirm = false`). `Err(ApprovalRequired)` when gated +/// and NOT in `approved_envs` (a blanket `--yes` never lands here). +fn env_gate_check( + project: Option<&ProjectDecl>, + env: Option<&str>, + approved_envs: &[String], +) -> Result { + let (Some(project), Some(env)) = (project, env) else { + return Ok(false); + }; + if !project.confirm_required(env) { + return Ok(false); + } + if approved_envs.iter().any(|e| e == env) { + Ok(true) + } else { + Err(ApplyError::ApprovalRequired(env.to_string())) + } +} + +/// §3 M3: require `AppAdmin`/`GroupAdmin` on every declared node of a tree apply +/// (the "override a gate" authority, above editor write caps). Mirrors +/// `authz_tree`'s resolution: an app resolves UUID-or-slug (fail-closed); a group +/// node whose slug names no existing group is to-CREATE (its create is separately +/// admin-gated in the service), so it is skipped here rather than 404'd. +async fn require_admin_on_every_node( + svc: &ApplyService, + principal: &Principal, + bundle: &TreeBundle, +) -> Result<(), ApplyError> { + for node in &bundle.nodes { + match node.kind { + NodeKind::App => { + let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?; + require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id)) + .await + .map_err(map_authz)?; + } + NodeKind::Group => { + if let Some(group) = svc + .groups + .get_by_slug(&node.slug) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + require( + svc.authz.as_ref(), + principal, + Capability::GroupAdmin(group.id), + ) + .await + .map_err(map_authz)?; + } + } + } + } + Ok(()) +} + +/// §3 M3: audit an approved gated-environment apply (actor + env + node count). +fn audit_gated_apply(principal: &Principal, env: Option<&str>, nodes: usize) { + tracing::info!( + user_id = ?principal.user_id, + env = ?env, + nodes, + "apply: approved gated-environment apply (audit)" + ); +} + /// Per-node capability check for a tree plan/apply. `prune` widens an apply's /// write requirement to every kind. A plan (`prune` ignored, no writes) needs /// only the per-node read cap. @@ -551,9 +668,10 @@ impl IntoResponse for ApplyError { StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), - Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })), - // §7 ownership conflict — actionable (declare [project], --takeover). - Self::OwnershipConflict(_) => { + // 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a + // §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7 + // ownership conflict (declare `[project]`, `--takeover`). + Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => { (StatusCode::CONFLICT, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 8e1f308..0eb96b6 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -480,6 +480,11 @@ pub struct PlanResult { /// conflict / unclaimed). Present only when a `[project]` was declared. #[serde(default, skip_serializing_if = "Option::is_none")] pub ownership: Option, + /// §3 M3: environments the governing project marks confirm-required — applying + /// to one needs `pic apply --approve `. Surfaced so CI sees the gate at + /// `plan` time, before an `apply` refuses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub approvals_required: Vec, } // ---------------------------------------------------------------------------- @@ -539,6 +544,41 @@ pub struct ProjectDecl { /// (no ceiling). #[serde(default)] pub parent_group: Option, + /// §3 M3 per-env approval policy (the root manifest's `[project.environments]`). + /// A `confirm = true` env makes applying to it a gated, admin-gated + audited + /// step (`--approve ` required; a blanket `--yes` does NOT cover it). The + /// server re-derives the gate from THIS field — the CLI's client-side check is + /// convenience, this is the authoritative source. `#[serde(default)]` keeps a + /// pre-M3 client (which omits it) backward-compatible (no gate). + #[serde(default)] + pub environments: std::collections::BTreeMap, +} + +/// §3 M3: one environment's server-side apply policy (mirrors the manifest's +/// `EnvPolicy`). `confirm = true` gates applying to this env. +#[derive(Debug, Clone, Deserialize)] +pub struct EnvPolicyDecl { + #[serde(default)] + pub confirm: bool, +} + +impl ProjectDecl { + /// Does environment `env` require explicit approval to apply (§3 M3)? + #[must_use] + pub fn confirm_required(&self, env: &str) -> bool { + self.environments.get(env).is_some_and(|p| p.confirm) + } + + /// Names of every confirm-required environment, sorted (for `plan` to surface + /// so CI sees the gate before an `apply` refuses). + #[must_use] + pub fn gated_envs(&self) -> Vec { + self.environments + .iter() + .filter(|(_, p)| p.confirm) + .map(|(name, _)| name.clone()) + .collect() + } } /// The ownership context threaded through an apply: the declared project (if @@ -664,6 +704,10 @@ pub struct NodePlan { pub struct TreePlanResult { pub nodes: Vec, pub state_token: String, + /// §3 M3: environments the project marks confirm-required (see + /// [`PlanResult::approvals_required`]). Surfaced so CI sees the gate at plan. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub approvals_required: Vec, } // ---------------------------------------------------------------------------- @@ -768,6 +812,13 @@ pub enum ApplyError { StateMoved, #[error("forbidden")] Forbidden, + /// §3 M3: applying to a confirm-required environment without an explicit + /// per-env approval. Maps to 409 — actionable (`--approve `). + #[error( + "environment `{0}` requires explicit approval to apply; \ + re-run with `--approve {0}` (a blanket `--yes` does not cover it)" + )] + ApprovalRequired(String), /// §7 multi-repo ownership: this apply's `[project]` does not own a node it /// touches (or the node is unclaimed and the apply declared no project). /// Maps to 409 — actionable (`use --takeover`, `declare [project]`). @@ -885,6 +936,7 @@ impl ApplyService { // `apply` can refuse if the node changed underneath it (§4.2). state_token: state_token_with_names(¤t, ¤t_names), ownership, + approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(), }) } @@ -1682,7 +1734,7 @@ impl ApplyService { let existing = self.load_existing_groups(bundle).await?; let resolved: HashMap = existing.iter().map(|(k, g)| (k.clone(), g.id)).collect(); - let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?; + let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved, project).await?; // §6/§7 M3: attach-point ceiling preview (consistent with apply_tree). if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) { let pg = self.resolve_group(pg_slug).await?; @@ -1720,6 +1772,7 @@ impl ApplyService { Ok(TreePlanResult { nodes, state_token: token, + approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(), }) } @@ -1766,7 +1819,9 @@ impl ApplyService { structure_mode, ) .await?; - let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?; + let (prepared, to_create, token) = self + .prepare_tree(bundle, &resolved, claim.project.as_ref()) + .await?; if !to_create.is_empty() { return Err(ApplyError::Backend( "internal: a group remained unresolved after create".into(), @@ -2285,6 +2340,7 @@ impl ApplyService { &self, bundle: &'a TreeBundle, resolved_ids: &HashMap, + project: Option<&ProjectDecl>, ) -> Result<(Vec>, Vec>, String), ApplyError> { if bundle.nodes.is_empty() { return Err(ApplyError::Invalid("project tree has no nodes".into())); @@ -2437,6 +2493,19 @@ impl ApplyService { token_parts.push(format!("gs|{gid}|{}", g.structure_version)); } } + // §3 M3: fold the env approval policy (desired state) into the token, so a + // policy edit between plan and apply trips StateMoved. Sorted key=confirm + // pairs for order-independence. Both plan_tree and apply_tree pass the + // same (root) project here, so the fold is symmetric. + if let Some(p) = project { + let mut envs: Vec = p + .environments + .iter() + .map(|(name, pol)| format!("{name}={}", pol.confirm)) + .collect(); + envs.sort_unstable(); + token_parts.push(format!("proj|{}", envs.join(","))); + } token_parts.sort_unstable(); Ok((prepared, to_create, combine_tokens(&token_parts))) } @@ -6029,4 +6098,31 @@ mod tests { "conflict" ); } + + #[test] + fn project_decl_gates_only_confirm_required_envs() { + // §3 M3: the server re-derives the gate from ProjectDecl.environments. + let mut environments = std::collections::BTreeMap::new(); + environments.insert("production".to_string(), EnvPolicyDecl { confirm: true }); + environments.insert("staging".to_string(), EnvPolicyDecl { confirm: false }); + let decl = ProjectDecl { + slug: "p".into(), + name: None, + parent_group: None, + environments, + }; + assert!(decl.confirm_required("production")); + assert!(!decl.confirm_required("staging")); + assert!(!decl.confirm_required("unknown")); + assert_eq!(decl.gated_envs(), vec!["production".to_string()]); + // A project with no policy gates nothing (backward-compatible default). + let bare = ProjectDecl { + slug: "p".into(), + name: None, + parent_group: None, + environments: std::collections::BTreeMap::new(), + }; + assert!(!bare.confirm_required("production")); + assert!(bare.gated_envs().is_empty()); + } } diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index fe1e106..72b00eb 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1276,6 +1276,8 @@ impl Client { project: Option<&crate::manifest::ManifestProject>, takeover: bool, expected_token: Option<&str>, + env: Option<&str>, + approved_envs: &[String], ) -> Result { let slug = seg(slug); let body = serde_json::json!({ @@ -1284,6 +1286,8 @@ impl Client { "expected_token": expected_token, "project": project, "takeover": takeover, + "env": env, + "approved_envs": approved_envs, }); let resp = self .request( @@ -1330,6 +1334,8 @@ impl Client { takeover: bool, expected_token: Option<&str>, structure_mode: &str, + env: Option<&str>, + approved_envs: &[String], ) -> Result { let body = serde_json::json!({ "bundle": bundle, @@ -1338,6 +1344,8 @@ impl Client { "project": project, "takeover": takeover, "structure_mode": structure_mode, + "env": env, + "approved_envs": approved_envs, }); let resp = self .request(Method::POST, "/api/v1/admin/tree/apply") @@ -1574,6 +1582,9 @@ pub struct PlanDto { /// §7 M3: the ownership outcome this apply would produce. #[serde(default)] pub ownership: Option, + /// §3 M3: envs the governing project marks confirm-required (need `--approve`). + #[serde(default)] + pub approvals_required: Vec, } #[derive(Debug, Deserialize)] @@ -1610,6 +1621,9 @@ pub struct TreePlanDto { pub nodes: Vec, #[serde(default)] pub state_token: String, + /// §3 M3: envs the project marks confirm-required (need `--approve `). + #[serde(default)] + pub approvals_required: Vec, } #[derive(Debug, Deserialize)] diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index d51ece5..f526938 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -68,9 +68,15 @@ pub async fn run( &slug, &bundle, prune, - manifest.project.as_ref(), + // §3 M3: send the GOVERNING project (own block, else nearest ancestor's) + // so the server receives the env-approval policy and can enforce the + // gate authoritatively. It is also the correct ownership project for + // this node (a leaf applying under its repo's project). + governing.as_ref(), takeover, expected_token.as_deref(), + env, + approve, ) .await?; crate::linkstate::clear_plan(base_dir, &slug); @@ -172,6 +178,8 @@ pub async fn run_tree( takeover, expected_token.as_deref(), structure_mode, + env, + approve, ) .await?; crate::linkstate::clear_plan(dir, token_key); @@ -247,7 +255,7 @@ pub async fn run_tree( /// honors the repo's env-approval gate. Loaded WITHOUT the env overlay — only /// the `[project]` block matters here, and `load_with_env` would require an /// overlay next to every ancestor and spuriously skip the governing manifest. -fn find_governing_project(manifest_path: &Path) -> Option { +pub(crate) fn find_governing_project(manifest_path: &Path) -> Option { let dir = manifest_path.parent()?; dir.ancestors().skip(1).find_map(|anc| { let candidate = anc.join(crate::manifest::MANIFEST_FILE); diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index b8c4be1..b4536fe 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -31,8 +31,15 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R NodeKind::App }; + // §3 M3: preview the gate against the GOVERNING project (own block, else the + // nearest ancestor's), symmetric with `apply::run` — so `pic plan --file` on + // a leaf still surfaces `approvals_required` for the gate the apply enforces. + let governing = manifest + .project + .clone() + .or_else(|| crate::cmds::apply::find_governing_project(manifest_path)); let plan = client - .plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref()) + .plan_node(kind, manifest.slug(), &bundle, governing.as_ref()) .await?; // Record the bound-plan token so a subsequent `pic apply` can detect the // node changing underneath the reviewed plan (best-effort — a read-only @@ -122,6 +129,18 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { } } table.print(mode); + render_approvals(&plan.approvals_required, mode); +} + +/// §3 M3: surface the confirm-required environments so CI sees the gate at plan +/// time, before an `apply --approve ` is needed. +fn render_approvals(approvals_required: &[String], mode: OutputMode) { + if mode != OutputMode::Json && !approvals_required.is_empty() { + eprintln!("\nenvironments requiring explicit approval (apply with --approve ):"); + for e in approvals_required { + eprintln!(" - {e}"); + } + } } /// Assemble the wire bundle: scripts carry inlined source (read from @@ -257,4 +276,5 @@ fn render(plan: &PlanDto, mode: OutputMode) { } } table.print(mode); + render_approvals(&plan.approvals_required, mode); } diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index b979668..d63ddda 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -286,9 +286,15 @@ pub struct ManifestProject { /// §3 M3 per-env approval policy: `[project.environments]` maps an env name /// to its policy. A `confirm = true` env requires an explicit `--approve /// ` on `pic apply --env ` (a blanket `--yes` does NOT cover it — - /// §4.2 "CI must opt in per environment"). Client-side gating only; never - /// sent to the server. - #[serde(default, skip_serializing)] + /// §4.2 "CI must opt in per environment"). The CLI gates client-side (fast + /// fail) AND sends the policy to the server, which re-derives the gate from + /// the request and enforces it (admin-gated on every declared node + audited). + /// This closes the "patched/older CLI that still declares the project but + /// skips the local check" bypass; it is NOT a hermetic boundary against a + /// request that omits the policy entirely (the policy lives in the applier's + /// manifest, not persisted server state — full closure would persist it). + /// Sent only when non-empty (pre-M3 wire otherwise). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub environments: std::collections::BTreeMap, } diff --git a/crates/picloud-cli/tests/env_approval.rs b/crates/picloud-cli/tests/env_approval.rs index c988f46..5725442 100644 --- a/crates/picloud-cli/tests/env_approval.rs +++ b/crates/picloud-cli/tests/env_approval.rs @@ -4,7 +4,13 @@ //! 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 gate is client-side, so a refused apply never reaches the server. +//! 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; @@ -13,6 +19,7 @@ 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 @@ -204,3 +211,129 @@ fn leaf_file_apply_honors_the_root_env_gate() { .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}" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index e494cc7..9dca4b3 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -827,14 +827,29 @@ divergence (`divergence_preview` → a `structure` row: `diverged`, server + man token folds `structure_version`). Pinned by `tests/structural_divergence.rs`. **§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the -root `[project]` maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, `#[serde(skip_serializing)]` -— purely CLI-side). `pic apply --env ` is REFUSED (client-side, before any request) when `` is confirm-required -unless it is explicitly `--approve `d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per -environment"); an unlisted / `confirm = false` env applies freely. `require_env_approval` gates both the single -(`run`) and tree (`run_tree`) apply paths; `--approve ` is a repeatable flag. Value overlays -(`picloud..toml`) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by -`tests/env_approval.rs`. **Deferred (documented, not built):** server-side audited approval-override capability -(§4.2 → `authz::can`); env-as-app mapping + declarative server-side `@E` config scoping. +root `[project]` maps an env name to `{ confirm = bool }`. `pic apply --env ` is REFUSED (client-side fast-fail, +before any request) when `` is confirm-required unless it is explicitly `--approve `d; a blanket `--yes` does +NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted / `confirm = false` env applies freely. +`require_env_approval` gates both the single (`run`) and tree (`run_tree`) apply paths; `--approve ` is a +repeatable flag. Value overlays (`picloud..toml`) already merge client-side, so this milestone is the +confirm-policy gate only. Pinned by `tests/env_approval.rs`. + +**Server-side enforcement shipped (2026-07-11).** The policy is no longer `skip_serializing` — the CLI SENDS +`[project.environments]` (on `ProjectDecl.environments`) plus the selected `--env` + the `--approve` set, and the +server RE-DERIVES the gate authoritatively (`env_gate_check` in `apply_api.rs`): a confirm-required env not in +`approved_envs` → **409 `ApprovalRequired`**; an approved override additionally requires **`AppAdmin`/`GroupAdmin` +on every declared node** (a step up from the editor write caps an ordinary apply needs — §4.2 "override a gate is +its own capability", reusing the admin caps) and emits a `tracing::info!` **audit** line. Enforced on all three +handlers (single app, single group, tree); the env policy folds into the **tree** bound-plan token (a policy edit +between plan and apply trips `StateMoved`); single-node `apply --file` now sends the GOVERNING project (own block +else nearest ancestor's), so a leaf apply carries the root's policy; `plan` surfaces `approvals_required`. Pinned by +`tests/env_approval.rs::{server_enforces_the_gate_against_a_non_stock_client, approving_a_gated_apply_requires_admin}`. +**Threat-model note (honest scope):** this closes the "patched/older CLI that still declares the project but skips +the local check" bypass, and admin-gates + audits the override. It is NOT a hermetic authorization boundary against +a hand-crafted request that OMITS the policy (or the `env` label) — the policy is applier-supplied, not persisted +server state, so an omitted policy is inherently ungated. **Full closure (deferred):** persist the env policy +server-side (a `project_environments` source of truth) + a server-determined env, so enforcement is independent of +what the request declares. **Also deferred:** env-as-app mapping + declarative server-side `@E` config scoping. **§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**