feat(apply): server-side enforcement of per-env approval gating (§3 M3 / §4.2)
The per-env approval gate (`[project.environments]`, `confirm = true`) was
client-side only — the policy was `#[serde(skip_serializing)]`, so a patched or
older CLI applying to a confirm-required env bypassed it entirely. This makes
the gate server-enforced, admin-gated, and audited (the deferred §4.2 piece).
- Wire: `ProjectDecl` gains `environments` (the CLI stops skip_serializing it and
sends the selected `env` + the `--approve` set on the apply request). The
server RE-DERIVES the gate from the request (`env_gate_check`): a
confirm-required env not in `approved_envs` → 409 `ApprovalRequired`.
- Admin gate: an approved override additionally requires `AppAdmin`/`GroupAdmin`
on EVERY declared node — a step up from the editor write caps an ordinary
apply needs (reusing the admin caps per §4.2) — and emits a `tracing::info!`
audit line. Enforced on all three handlers (single app, single group, tree);
the check runs before any tx, so a denial (403) precedes any mutation.
- Token: the env policy folds into the TREE bound-plan token (a policy edit
between plan and apply trips StateMoved). The single-node token is left
unchanged (a bare live-state hash), so plan/apply still match without threading
a project into it.
- CLI: single-node `apply`/`plan` now resolve the GOVERNING project (own block
else nearest ancestor's) so a leaf apply carries the root's policy; `plan`
surfaces `approvals_required`. Client-side `require_env_approval` fast-fail
kept as UX.
Adapts the earlier feat/ownership-and-approval M5 (654e387) to main's DTOs; main
addresses tree group nodes by slug only, so the admin loop mirrors authz_tree
(app: resolve_app_id fail-closed; group: get_by_slug, skip to-create).
Threat-model note (honest scope, in the doc + code comments): this closes the
patched/older-CLI bypass and admin-gates + audits the override, but is NOT a
hermetic boundary against a hand-crafted request that OMITS the policy — the
policy is applier-supplied, not persisted server state. Full closure (persist a
`project_environments` source of truth + a server-determined env) is deferred.
Review (adversarial pass): fixed a MEDIUM (single-node `plan` didn't resolve the
governing project, so a leaf plan under-reported the gate `apply` enforces) and a
LOW (duplicate clippy attr); corrected overstated "can't bypass" wording.
Tests: ProjectDecl gate unit test; env_approval journeys
`server_enforces_the_gate_against_a_non_stock_client` (raw request → 409 without
approval, admin-approved → 200) and `approving_a_gated_apply_requires_admin`
(editor + --approve → 403). 417 lib tests + 33 CLI journeys + workspace clippy
-D warnings + fmt clean. No migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -827,14 +827,29 @@ divergence (`divergence_preview` → a `structure` row: `diverged`, server + man
|
||||
token folds `structure_version`). Pinned by `tests/structural_divergence.rs`.
|
||||
|
||||
**§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the
|
||||
root `[project]` maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, `#[serde(skip_serializing)]`
|
||||
— purely CLI-side). `pic apply --env <e>` is REFUSED (client-side, before any request) when `<e>` is confirm-required
|
||||
unless it is explicitly `--approve <e>`d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per
|
||||
environment"); an unlisted / `confirm = false` env applies freely. `require_env_approval` gates both the single
|
||||
(`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a repeatable flag. Value overlays
|
||||
(`picloud.<env>.toml`) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by
|
||||
`tests/env_approval.rs`. **Deferred (documented, not built):** server-side audited approval-override capability
|
||||
(§4.2 → `authz::can`); env-as-app mapping + declarative server-side `@E` config scoping.
|
||||
root `[project]` maps an env name to `{ confirm = bool }`. `pic apply --env <e>` is REFUSED (client-side fast-fail,
|
||||
before any request) when `<e>` is confirm-required unless it is explicitly `--approve <e>`d; a blanket `--yes` does
|
||||
NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted / `confirm = false` env applies freely.
|
||||
`require_env_approval` gates both the single (`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a
|
||||
repeatable flag. Value overlays (`picloud.<env>.toml`) already merge client-side, so this milestone is the
|
||||
confirm-policy gate only. Pinned by `tests/env_approval.rs`.
|
||||
|
||||
**Server-side enforcement shipped (2026-07-11).** The policy is no longer `skip_serializing` — the CLI SENDS
|
||||
`[project.environments]` (on `ProjectDecl.environments`) plus the selected `--env` + the `--approve` set, and the
|
||||
server RE-DERIVES the gate authoritatively (`env_gate_check` in `apply_api.rs`): a confirm-required env not in
|
||||
`approved_envs` → **409 `ApprovalRequired`**; an approved override additionally requires **`AppAdmin`/`GroupAdmin`
|
||||
on every declared node** (a step up from the editor write caps an ordinary apply needs — §4.2 "override a gate is
|
||||
its own capability", reusing the admin caps) and emits a `tracing::info!` **audit** line. Enforced on all three
|
||||
handlers (single app, single group, tree); the env policy folds into the **tree** bound-plan token (a policy edit
|
||||
between plan and apply trips `StateMoved`); single-node `apply --file` now sends the GOVERNING project (own block
|
||||
else nearest ancestor's), so a leaf apply carries the root's policy; `plan` surfaces `approvals_required`. Pinned by
|
||||
`tests/env_approval.rs::{server_enforces_the_gate_against_a_non_stock_client, approving_a_gated_apply_requires_admin}`.
|
||||
**Threat-model note (honest scope):** this closes the "patched/older CLI that still declares the project but skips
|
||||
the local check" bypass, and admin-gates + audits the override. It is NOT a hermetic authorization boundary against
|
||||
a hand-crafted request that OMITS the policy (or the `env` label) — the policy is applier-supplied, not persisted
|
||||
server state, so an omitted policy is inherently ungated. **Full closure (deferred):** persist the env policy
|
||||
server-side (a `project_environments` source of truth) + a server-determined env, so enforcement is independent of
|
||||
what the request declares. **Also deferred:** env-as-app mapping + declarative server-side `@E` config scoping.
|
||||
|
||||
**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user