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:
MechaCat02
2026-06-28 19:06:27 +02:00
parent c04c684a2e
commit 22d6c33d0b
13 changed files with 519 additions and 12 deletions

View File

@@ -1064,9 +1064,18 @@ Resolved items now live inline next to their topic. What genuinely remains:
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below. Still
> deferred: **per-env approval-policy gating** (`[project.environments]`); env overlays
> (`picloud.<env>.toml`) already exist.
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below.
> **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-06-28)** — the root
> manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such an
> env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
> admin-gated (the actor needs admin on every declared node, not just write) and audited, and the
> policy folds into the bound-plan token. The server re-derives the policy from the bundle — the CLI's
> check is convenience, the server is authoritative. Env overlays (`picloud.<env>.toml`) already exist.
> Gating is a **`--dir` (project-tree) feature**: single-node `pic apply --file --env <e>` cannot
> carry the admin-gated approval, so it **refuses** a confirm-required env and directs the user to
> `--dir` (rather than silently bypassing the gate). Like `--takeover`/blast-radius, the policy lives
> in the manifest (not persisted server-side), so whoever controls desired state can weaken it — an
> accepted trust boundary, not a privilege bug.
>
> Shipped surface:
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
@@ -1131,3 +1140,7 @@ to take, not yet taken:
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
> `[project]` (M5, root manifest only) shipped: `[[project.environments]]` entries carry
> `name` + `confirm` (default `false`). A `confirm = true` env is gated — `pic apply --dir --env <name>`
> requires `--approve <name>` (admin-gated + audited; `--yes` is insufficient). The block is rejected on
> any non-root manifest. Remaining unspecified: storage config, domains, the full secret-ref shape.