feat(project-tool): per-env approval-policy gating (§4.2/§6)

Salvaged from the superseded feat/hierarchies-extension-points branch (M5),
re-ported onto main's evolved tree-apply engine. Fills the §7/§11.1 "per-env
approval-policy gating" that main's design doc still lists as intended but
unbuilt.

A project's root manifest declares which environments require explicit
approval; `pic apply --dir --env <e>` to a confirm-required env needs an
explicit `--approve <e>` (a blanket `--yes` does NOT cover it). The approval
is admin-gated (admin on every declared node) and audited, and the policy
folds into the bound-plan token.

- manifest: `[project]` block → ManifestProject { environments[{name,confirm}] },
  root-manifest-only (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; plan surfaces approvals_required; ApplyError::ApprovalRequired
  → 409.
- apply_api: enforce_env_approval (after authz_tree) — refuses a gated env not
  in approved_envs, requires AppAdmin/GroupAdmin on every declared node on
  approval, audits. Server re-derives policy from the bundle (authoritative).
- CLI: --approve (repeatable); resolve_approvals refuses a gated env
  non-interactively, prompts on a TTY; plan renders gated envs. Single-node
  apply --file refuses a confirm-required env and directs to --dir.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
  lives in the manifest, like takeover/blast-radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-05 19:51:11 +02:00
parent ae98063f87
commit 654e38752d
13 changed files with 523 additions and 13 deletions

View File

@@ -287,6 +287,15 @@ struct TreeApplyRequest {
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// Selected environment (the overlay applied). Drives per-env approval
/// gating (§4.2, M5).
#[serde(default)]
env: Option<String>,
/// Environments the actor explicitly approved this apply for (§4.2, M5). A
/// confirm-required env (per the bundle's `[project]` policy) is refused
/// unless it appears here; `--yes` alone does NOT add it.
#[serde(default)]
approved_envs: Vec<String>,
}
async fn tree_plan_handler(
@@ -304,6 +313,14 @@ async fn tree_apply_handler(
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
enforce_env_approval(
&svc,
&principal,
&req.bundle,
req.env.as_deref(),
&req.approved_envs,
)
.await?;
let report = svc
.apply_tree(
&req.bundle,
@@ -315,6 +332,62 @@ async fn tree_apply_handler(
Ok(Json(report))
}
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2), a
/// deliberate step up from the editor-level write caps an ordinary apply needs —
/// and is audited.
async fn enforce_env_approval(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
env: Option<&str>,
approved_envs: &[String],
) -> Result<(), ApplyError> {
let (Some(policy), Some(env)) = (&bundle.project, env) else {
return Ok(());
};
if !policy.confirm_required(env) {
return Ok(());
}
if !approved_envs.iter().any(|e| e == env) {
return Err(ApplyError::ApprovalRequired(env.to_string()));
}
// Approved: require admin authority on every declared node.
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(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
.await
.map_err(map_authz)?;
}
}
}
}
tracing::info!(
user_id = ?principal.user_id,
env = %env,
nodes = bundle.nodes.len(),
"apply: approved gated-environment apply (audit)"
);
Ok(())
}
/// 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.
@@ -503,7 +576,9 @@ impl IntoResponse for ApplyError {
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::StateMoved | Self::ApprovalRequired(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apply authz repo error");

View File

@@ -505,6 +505,46 @@ pub struct TreeNode {
pub struct TreeBundle {
#[serde(default)]
pub nodes: Vec<TreeNode>,
/// Project-level policy from the repo's root manifest `[project]` block
/// (§4.2/§6, M5). The server re-derives env approval policy from here — the
/// CLI's enforcement is convenience, this is authoritative.
#[serde(default)]
pub project: Option<ProjectPolicy>,
}
/// Project-level policy (the root manifest's `[project]` block, M5).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProjectPolicy {
#[serde(default)]
pub environments: Vec<EnvPolicy>,
}
/// One environment's apply policy. `confirm = true` makes applying to this env a
/// gated, default-off step: a per-env `--approve <name>` is required (a blanket
/// `--yes` does NOT cover it) and the act is admin-gated + audited (§4.2).
#[derive(Debug, Clone, Deserialize)]
pub struct EnvPolicy {
pub name: String,
#[serde(default)]
pub confirm: bool,
}
impl ProjectPolicy {
/// Does environment `env` require explicit approval to apply?
#[must_use]
pub fn confirm_required(&self, env: &str) -> bool {
self.environments.iter().any(|e| e.name == env && e.confirm)
}
/// Names of every confirm-required environment (for `plan` to surface).
#[must_use]
pub fn gated_envs(&self) -> Vec<String> {
self.environments
.iter()
.filter(|e| e.confirm)
.map(|e| e.name.clone())
.collect()
}
}
/// One node's diff within a tree plan.
@@ -523,6 +563,11 @@ pub struct NodePlan {
pub struct TreePlanResult {
pub nodes: Vec<NodePlan>,
pub state_token: String,
/// Environments the project marks confirm-required (§4.2, M5): applying to
/// one needs an explicit `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>,
}
// ----------------------------------------------------------------------------
@@ -627,6 +672,11 @@ pub enum ApplyError {
StateMoved,
#[error("forbidden")]
Forbidden,
#[error(
"environment `{0}` requires explicit approval to apply; \
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
)]
ApprovalRequired(String),
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("backend: {0}")]
@@ -1463,9 +1513,15 @@ impl ApplyService {
plan: p.plan,
})
.collect();
let approvals_required = bundle
.project
.as_ref()
.map(ProjectPolicy::gated_envs)
.unwrap_or_default();
Ok(TreePlanResult {
nodes,
state_token: token,
approvals_required,
})
}
@@ -1719,6 +1775,18 @@ impl ApplyService {
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
}
}
// Fold the env approval policy (desired state) into the token, so a
// policy edit between plan and apply trips StateMoved (M5). Sorted for
// order-independence.
if let Some(policy) = &bundle.project {
let mut envs: Vec<String> = policy
.environments
.iter()
.map(|e| format!("{}={}", e.name, e.confirm))
.collect();
envs.sort_unstable();
token_parts.push(format!("proj|{}", envs.join(",")));
}
token_parts.sort_unstable();
Ok((prepared, combine_tokens(&token_parts)))
}
@@ -4732,4 +4800,24 @@ mod tests {
"queue identity is queue_name-only: {p:?}"
);
}
#[test]
fn project_policy_gates_only_confirm_required_envs() {
let policy = ProjectPolicy {
environments: vec![
EnvPolicy {
name: "production".into(),
confirm: true,
},
EnvPolicy {
name: "staging".into(),
confirm: false,
},
],
};
assert!(policy.confirm_required("production"));
assert!(!policy.confirm_required("staging"));
assert!(!policy.confirm_required("unknown"));
assert_eq!(policy.gated_envs(), vec!["production".to_string()]);
}
}