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:
MechaCat02
2026-07-11 14:01:04 +02:00
parent bbfdb1bf47
commit 4b4b3148b7
8 changed files with 431 additions and 21 deletions

View File

@@ -205,12 +205,22 @@ pub struct ApplyRequest {
/// refuses (409) if the app's live state has changed since.
#[serde(default)]
pub expected_token: Option<String>,
/// §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<ProjectDecl>,
/// §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<String>,
/// §3 M3: environments the actor explicitly approved (`--approve <env>`). A
/// confirm-required env is refused unless it appears here; `--yes` alone does
/// NOT add it.
#[serde(default)]
pub approved_envs: Vec<String>,
}
async fn apply_handler(
@@ -221,6 +231,15 @@ async fn apply_handler(
) -> Result<Json<ApplyReport>, 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<String>,
/// §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<ProjectDecl>,
#[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<String>,
/// §3 M3: environments the actor explicitly approved (`--approve <env>`).
#[serde(default)]
approved_envs: Vec<String>,
}
#[derive(Deserialize)]
@@ -364,6 +401,13 @@ async fn tree_apply_handler(
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, 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<bool, ApplyError> {
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() })),

View File

@@ -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(&current, &current_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());
}
}