feat(apply): server-side enforcement of per-env approval gating (§3 M3 / §4.2)
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>
This commit is contained in:
@@ -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<OwnershipPreview>,
|
||||
/// §3 M3: environments the governing project marks confirm-required — applying
|
||||
/// to one needs `pic apply --approve <env>`. 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<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -539,6 +544,41 @@ pub struct ProjectDecl {
|
||||
/// (no ceiling).
|
||||
#[serde(default)]
|
||||
pub parent_group: Option<String>,
|
||||
/// §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 <env>` 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<String, EnvPolicyDecl>,
|
||||
}
|
||||
|
||||
/// §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<String> {
|
||||
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<NodePlan>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -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 <env>`).
|
||||
#[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<String, GroupId> =
|
||||
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<String, GroupId>,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, 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<String> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user