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

@@ -45,10 +45,15 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
Ok(())
}
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
/// that names the same (kind, slug) twice.
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
@@ -56,11 +61,25 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
root.display()
);
}
let root_manifest = root.join(MANIFEST_FILE);
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path == &root_manifest {
project = Some(p.clone());
} else {
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
}
}
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
@@ -71,5 +90,20 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
}
let count = nodes.len();
Ok((json!({ "nodes": nodes }), count))
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}