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:
@@ -205,12 +205,22 @@ pub struct ApplyRequest {
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
/// §7 ownership: the declaring repo's `[project]` (absent = no claim).
|
||||
/// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also
|
||||
/// carries the §3 M3 env approval policy (`project.environments`).
|
||||
#[serde(default)]
|
||||
pub project: Option<ProjectDecl>,
|
||||
/// §7 ownership: reassign a node owned by another project (group-admin).
|
||||
#[serde(default)]
|
||||
pub takeover: bool,
|
||||
/// §3 M3: the environment this apply targets (the overlay merged). Drives the
|
||||
/// per-env approval gate when the project marks it `confirm = true`.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
/// §3 M3: environments the actor explicitly approved (`--approve <env>`). A
|
||||
/// confirm-required env is refused unless it appears here; `--yes` alone does
|
||||
/// NOT add it.
|
||||
#[serde(default)]
|
||||
pub approved_envs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
@@ -221,6 +231,15 @@ async fn apply_handler(
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate. If the target env is
|
||||
// confirm-required and approved, the override additionally requires ADMIN on
|
||||
// the node (a step up from the editor write caps above) + is audited.
|
||||
if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? {
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
audit_gated_apply(&principal, req.env.as_deref(), 1);
|
||||
}
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
@@ -298,6 +317,17 @@ async fn group_apply_handler(
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
|
||||
.await?;
|
||||
// §3 M3: per-env approval gate — an approved gated apply requires GroupAdmin.
|
||||
if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
audit_gated_apply(&principal, req.env.as_deref(), 1);
|
||||
}
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
@@ -328,6 +358,7 @@ struct TreeApplyRequest {
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
||||
/// Also carries the §3 M3 env approval policy (`project.environments`).
|
||||
#[serde(default)]
|
||||
project: Option<ProjectDecl>,
|
||||
#[serde(default)]
|
||||
@@ -336,6 +367,12 @@ struct TreeApplyRequest {
|
||||
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
|
||||
#[serde(default)]
|
||||
structure_mode: StructureMode,
|
||||
/// §3 M3: the environment this apply targets (drives the per-env approval gate).
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// §3 M3: environments the actor explicitly approved (`--approve <env>`).
|
||||
#[serde(default)]
|
||||
approved_envs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -364,6 +401,13 @@ async fn tree_apply_handler(
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate. On an approved gated
|
||||
// apply, require ADMIN on EVERY declared node (a step up from the editor
|
||||
// write caps authz_tree checks) + audit.
|
||||
if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? {
|
||||
require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
|
||||
audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len());
|
||||
}
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
@@ -381,6 +425,79 @@ async fn tree_apply_handler(
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// §3 M3: the per-env approval gate, re-derived from the project's policy on the
|
||||
/// wire (the CLI's client-side check is convenience; THIS is authoritative).
|
||||
/// Returns `Ok(true)` when the target env is confirm-required AND approved — the
|
||||
/// caller must then require admin + audit. `Ok(false)` when not gated (no
|
||||
/// project, no env, or `confirm = false`). `Err(ApprovalRequired)` when gated
|
||||
/// and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
||||
fn env_gate_check(
|
||||
project: Option<&ProjectDecl>,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<bool, ApplyError> {
|
||||
let (Some(project), Some(env)) = (project, env) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !project.confirm_required(env) {
|
||||
return Ok(false);
|
||||
}
|
||||
if approved_envs.iter().any(|e| e == env) {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(ApplyError::ApprovalRequired(env.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// §3 M3: require `AppAdmin`/`GroupAdmin` on every declared node of a tree apply
|
||||
/// (the "override a gate" authority, above editor write caps). Mirrors
|
||||
/// `authz_tree`'s resolution: an app resolves UUID-or-slug (fail-closed); a group
|
||||
/// node whose slug names no existing group is to-CREATE (its create is separately
|
||||
/// admin-gated in the service), so it is skipped here rather than 404'd.
|
||||
async fn require_admin_on_every_node(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
NodeKind::Group => {
|
||||
if let Some(group) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3 M3: audit an approved gated-environment apply (actor + env + node count).
|
||||
fn audit_gated_apply(principal: &Principal, env: Option<&str>, nodes: usize) {
|
||||
tracing::info!(
|
||||
user_id = ?principal.user_id,
|
||||
env = ?env,
|
||||
nodes,
|
||||
"apply: approved gated-environment apply (audit)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
@@ -551,9 +668,10 @@ impl IntoResponse for ApplyError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
// §7 ownership conflict — actionable (declare [project], --takeover).
|
||||
Self::OwnershipConflict(_) => {
|
||||
// 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a
|
||||
// §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7
|
||||
// ownership conflict (declare `[project]`, `--takeover`).
|
||||
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
|
||||
Reference in New Issue
Block a user