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