feat(hierarchies): per-env approval-policy gating (M5)
A project's root manifest can mark environments confirm-required; applying to
one needs an explicit `pic apply --dir --env <e> --approve <e>` (a blanket
`--yes` does NOT cover it), the act is admin-gated and audited, and the policy
folds into the bound-plan token. The last unbuilt piece of the project-tool
track (§4.2/§6).
- manifest: `[project]` block → `ManifestProject { environments[{name,confirm}] }`,
valid only on the tree's ROOT manifest (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 (a policy edit between plan and apply trips StateMoved); plan
surfaces `approvals_required`; new `ApplyError::ApprovalRequired` → 409.
- apply_api: `enforce_env_approval` (after authz_tree) — refuses a gated env
not in `approved_envs`, and on approval requires admin (AppAdmin/GroupAdmin)
on every declared node + audits. The server re-derives the policy from the
bundle (CLI check is convenience; server is authoritative).
- CLI: `--approve <env>` (repeatable); `resolve_approvals` refuses a gated env
non-interactively, prompts on a TTY (retype the env name); plan renders gated
envs. Single-node `apply --file --env <e>` REFUSES a confirm-required env
(can't carry the admin-gated approval) and directs to `--dir` — closing the
bypass found in review rather than silently skipping the gate.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
lives in the manifest, like takeover/blast-radius).
- doc §11 Phase 5 + §12: approval gating shipped; gate is a `--dir` feature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,11 @@ struct TreeApplyRequest {
|
||||
/// placeholder when expanding route templates. The CLI passes `--env`.
|
||||
#[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(
|
||||
@@ -233,6 +238,14 @@ async fn tree_apply_handler(
|
||||
req.allow_takeover,
|
||||
)
|
||||
.await?;
|
||||
enforce_env_approval(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
req.env.as_deref(),
|
||||
&req.approved_envs,
|
||||
)
|
||||
.await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
@@ -438,6 +451,64 @@ async fn authz_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 => {
|
||||
// A to-create group has no id yet; its creation already required
|
||||
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// App-node write caps: per resource kind the bundle touches, widened to all
|
||||
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
||||
/// is always needed. Shared by the single-app and tree apply paths.
|
||||
@@ -679,7 +750,7 @@ impl IntoResponse for ApplyError {
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::OwnershipConflict(_) => {
|
||||
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
|
||||
@@ -453,6 +453,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.
|
||||
@@ -485,6 +525,11 @@ pub struct TreePlanResult {
|
||||
/// expansions. Lets CI gauge the fan-out before applying.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub template_blast_radius: Vec<TemplateBlastRadius>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// One group's route-template fan-out within the current apply.
|
||||
@@ -552,6 +597,11 @@ pub enum ApplyError {
|
||||
re-run with `--takeover` to claim them (group-admin required): {0}"
|
||||
)]
|
||||
OwnershipConflict(String),
|
||||
#[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}")]
|
||||
@@ -1268,12 +1318,18 @@ impl ApplyService {
|
||||
plan: compute_diff(&CurrentState::default(), &c.node.bundle),
|
||||
});
|
||||
}
|
||||
let approvals_required = bundle
|
||||
.project
|
||||
.as_ref()
|
||||
.map(ProjectPolicy::gated_envs)
|
||||
.unwrap_or_default();
|
||||
Ok(TreePlanResult {
|
||||
nodes,
|
||||
state_token: pt.token,
|
||||
conflicts: pt.conflicts,
|
||||
structural_prunes: pt.prune_candidates.into_iter().map(|(_, s)| s).collect(),
|
||||
template_blast_radius: pt.template_blast_radius,
|
||||
approvals_required,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2010,6 +2066,19 @@ impl ApplyService {
|
||||
});
|
||||
}
|
||||
|
||||
// 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(",")));
|
||||
}
|
||||
|
||||
// Fold in every in-scope group's structure version, so a reparent or a
|
||||
// new app under the subtree between plan and apply trips StateMoved.
|
||||
for gid in &versioned_groups {
|
||||
@@ -5850,4 +5919,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()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user