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

@@ -895,6 +895,49 @@ impl ApplyService {
.await
}
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an
/// apply/plan — `env_name -> confirm`, computed as `persisted declared`
/// (an env is confirm-required if EITHER the stored policy OR the request's
/// `[project.environments]` marks it so). Loading the persisted side is what
/// makes the gate hermetic: a request that omits (or flips-to-false) a policy
/// a prior apply persisted still trips the gate. Empty when no project is
/// declared. A persisted-read failure surfaces as `Backend` (fail-closed —
/// we never silently drop a gate).
pub async fn effective_env_policy(
&self,
project: Option<&ProjectDecl>,
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
let Some(project) = project else {
return Ok(std::collections::BTreeMap::new());
};
let mut effective = self
.projects
.get_environments_by_slug(&project.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
for (env, pol) in &project.environments {
// Union on confirm: a declared gate strengthens, never weakens.
let entry = effective.entry(env.clone()).or_insert(false);
*entry = *entry || pol.confirm;
}
Ok(effective)
}
/// The sorted names of every effectively-gated env (`effective_env_policy`
/// filtered to `confirm`), surfaced as `plan.approvals_required`.
async fn effective_gated_envs(
&self,
project: Option<&ProjectDecl>,
) -> Result<Vec<String>, ApplyError> {
let policy = self.effective_env_policy(project).await?;
let mut gated: Vec<String> = policy
.into_iter()
.filter_map(|(env, confirm)| confirm.then_some(env))
.collect();
gated.sort_unstable();
Ok(gated)
}
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
/// A group node has only scripts + vars — routes/triggers are rejected, and
/// the app-only inherited-target + route-host validation is skipped.
@@ -936,7 +979,7 @@ impl ApplyService {
// `apply` can refuse if the node changed underneath it (§4.2).
state_token: state_token_with_names(&current, &current_names),
ownership,
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
approvals_required: self.effective_gated_envs(project).await?,
})
}
@@ -1772,7 +1815,7 @@ impl ApplyService {
Ok(TreePlanResult {
nodes,
state_token: token,
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
approvals_required: self.effective_gated_envs(project).await?,
})
}
@@ -4072,6 +4115,28 @@ async fn upsert_project_tx(
.fetch_one(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §3 M3 (hermetic gate): persist the declared per-env approval policy as the
// authoritative source. Declarative replace (delete-then-insert) so a
// removed env drops out — the gate reads `persisted declared`, so an
// ungating still has to pass the prior gate at apply time (see
// `apply_api::env_gate_check`). Same tx as the project row + node claim.
sqlx::query("DELETE FROM project_environments WHERE project_id = $1")
.bind(id)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
for (env_name, pol) in &decl.environments {
sqlx::query(
"INSERT INTO project_environments (project_id, env_name, confirm) \
VALUES ($1, $2, $3)",
)
.bind(id)
.bind(env_name)
.bind(pol.confirm)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
}
Ok(id.into())
}