feat(apply): make the per-env approval gate hermetic (§3 M3 / Track A M1)

The per-env approval policy was applier-supplied — a hand-crafted request
that omitted `project.environments` was ungated, and flipping a gate to
`confirm = false` in the same request un-gated it. Persist the policy
server-side and enforce against `persisted ∪ declared`.

- Migration 0067: `project_environments (project_id, env_name, confirm)`,
  CASCADE on the project. Written declaratively (delete-then-insert) inside
  `upsert_project_tx`, same tx as the project row + node claim.
- `ProjectRepository::get_environments_by_slug` (read side) +
  `ApplyService::effective_env_policy` union the persisted policy with the
  request's declared one (confirm if EITHER says so — monotonic).
- `env_gate_check` now evaluates the effective policy; the three handlers load
  it before the gate. `plan.approvals_required` is the effective gated set, so
  CI sees a persisted gate even when the manifest omits it.
- The union rule closes both bypasses: an omitted policy still trips a
  persisted gate, and an ungating apply must itself pass the gate (the
  declarative replace then takes effect next time) — TOCTOU-safe.

Pinned by env_approval::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates} +
projects_repo::get_environments_by_slug_returns_persisted_policy. Schema golden
reblessed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:31:15 +02:00
parent 4b4b3148b7
commit c06a9e801e
8 changed files with 350 additions and 34 deletions

View File

@@ -231,10 +231,13 @@ 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)? {
// §3 M3: server-authoritative per-env approval gate, evaluated against the
// persisted declared policy (hermetic — an omitted policy can't bypass a
// gate a prior apply established). If gated and approved, the override
// additionally requires ADMIN on the node (a step up from the editor write
// caps above) + is audited.
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
.await
.map_err(map_authz)?;
@@ -317,8 +320,10 @@ 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)? {
// §3 M3: per-env approval gate (persisted declared) — an approved gated
// apply requires GroupAdmin.
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
require(
svc.authz.as_ref(),
&principal,
@@ -401,10 +406,11 @@ 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)? {
// §3 M3: server-authoritative per-env approval gate (persisted declared).
// On an approved gated apply, require ADMIN on EVERY declared node (a step up
// from the editor write caps authz_tree checks) + audit.
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
if env_gate_check(&policy, 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());
}
@@ -425,21 +431,23 @@ 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).
/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
/// EFFECTIVE policy (`persisted declared`, built by
/// `ApplyService::effective_env_policy`) — the persisted side is authoritative,
/// so a request that omits or weakens the policy can't bypass a gate a prior
/// apply established. 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 env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
fn env_gate_check(
project: Option<&ProjectDecl>,
policy: &std::collections::BTreeMap<String, bool>,
env: Option<&str>,
approved_envs: &[String],
) -> Result<bool, ApplyError> {
let (Some(project), Some(env)) = (project, env) else {
let Some(env) = env else {
return Ok(false);
};
if !project.confirm_required(env) {
if !policy.get(env).copied().unwrap_or(false) {
return Ok(false);
}
if approved_envs.iter().any(|e| e == env) {