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:
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user