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:
@@ -1276,6 +1276,8 @@ impl Client {
|
||||
project: Option<&crate::manifest::ManifestProject>,
|
||||
takeover: bool,
|
||||
expected_token: Option<&str>,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<ApplyReportDto> {
|
||||
let slug = seg(slug);
|
||||
let body = serde_json::json!({
|
||||
@@ -1284,6 +1286,8 @@ impl Client {
|
||||
"expected_token": expected_token,
|
||||
"project": project,
|
||||
"takeover": takeover,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
});
|
||||
let resp = self
|
||||
.request(
|
||||
@@ -1330,6 +1334,8 @@ impl Client {
|
||||
takeover: bool,
|
||||
expected_token: Option<&str>,
|
||||
structure_mode: &str,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
@@ -1338,6 +1344,8 @@ impl Client {
|
||||
"project": project,
|
||||
"takeover": takeover,
|
||||
"structure_mode": structure_mode,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
@@ -1574,6 +1582,9 @@ pub struct PlanDto {
|
||||
/// §7 M3: the ownership outcome this apply would produce.
|
||||
#[serde(default)]
|
||||
pub ownership: Option<OwnershipPreviewDto>,
|
||||
/// §3 M3: envs the governing project marks confirm-required (need `--approve`).
|
||||
#[serde(default)]
|
||||
pub approvals_required: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1610,6 +1621,9 @@ pub struct TreePlanDto {
|
||||
pub nodes: Vec<NodePlanDto>,
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
/// §3 M3: envs the project marks confirm-required (need `--approve <env>`).
|
||||
#[serde(default)]
|
||||
pub approvals_required: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user