Remediate the HIGH and security-relevant findings from the 2026-07-11 audit. H1 — the per-env approval gate is now server-authoritative. The governing project is resolved from the target node's nearest-claimed ancestor (`governing_env_policy`/`_tree` + `governing_project_id` + `ProjectRepository::get_environments_by_id`), independent of the client-supplied `[project]`. Omitting or spoofing the project block can no longer skip a gate the owning project established; a to-create group resolves its declared parent's chain so a fresh subtree node inherits the gate. Fails closed on any read error. H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe `rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request task (unauthenticated per-request DoS). Regression test added. C1 — admin sessions gain an absolute lifetime cap (migration 0070, `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch` clamps the sliding bump to it, so a continuously-used or stolen-but-warm token self-expires. Mirrors the data-plane app-user cap. C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two that return a raw credential), so a proxy/CDN/browser cache can't retain it. B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and group download paths now set attachment + `X-Content-Type-Options: nosniff` + a restrictive CSP, closing a group-path stored-XSS gap. Also folds in the server-side plan-warning plumbing (`plan_warnings`, `PlanResult::warnings`) and the `app_only_reject` message helper that the CLI plan-preview change builds on, plus operator security notes (reads-open shared- topic SSE; the `--env` label is advisory, not a boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
540 lines
20 KiB
Rust
540 lines
20 KiB
Rust
//! §3 M3 — per-env approval gating (`[project.environments]`).
|
||
//!
|
||
//! A `[project.environments]` block marks some envs confirm-required. Applying
|
||
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
|
||
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
|
||
//! opt in per environment"). An unlisted or `confirm = false` env applies
|
||
//! freely. The CLI gates client-side (fast fail) AND the SERVER enforces it — a
|
||
//! confirm-required apply is refused (409) unless approved, and an approved
|
||
//! override additionally requires ADMIN on every declared node (a step up from
|
||
//! editor write caps) and is audited.
|
||
//!
|
||
//! §3 M3 HERMETIC gate: the policy is PERSISTED server-side (`project_environments`,
|
||
//! 0067) on every apply, and the gate evaluates against `persisted ∪ declared` —
|
||
//! so a request that OMITS the policy (or flips a gate to `false`) can no longer
|
||
//! bypass a gate a prior apply established. This closes both the patched/older-CLI
|
||
//! bypass AND the hand-crafted-request bypass.
|
||
|
||
use std::fs;
|
||
use std::path::Path;
|
||
|
||
use tempfile::TempDir;
|
||
|
||
use crate::common;
|
||
use crate::common::cleanup::GroupGuard;
|
||
use crate::common::member;
|
||
|
||
/// A repo whose `[project]` gates `production` (confirm-required) but not
|
||
/// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files
|
||
/// exist so `--env` can load them.
|
||
fn gated_repo(dir: &Path, project: &str, group: &str) {
|
||
fs::write(
|
||
dir.join("picloud.toml"),
|
||
format!(
|
||
"[project]\nslug = \"{project}\"\n\n\
|
||
[project.environments]\nproduction = {{ confirm = true }}\n\
|
||
staging = {{ confirm = false }}\n\n\
|
||
[group]\nslug = \"{group}\"\nname = \"Env Gated\"\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
dir.join("picloud.production.toml"),
|
||
"# production overlay\n",
|
||
)
|
||
.unwrap();
|
||
fs::write(dir.join("picloud.staging.toml"), "# staging overlay\n").unwrap();
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn confirm_required_env_needs_explicit_approve() {
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("ea-grp");
|
||
let project = common::unique_slug("ea-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
let dir = TempDir::new().unwrap();
|
||
gated_repo(dir.path(), &project, &group);
|
||
let manifest = dir.path().join("picloud.toml");
|
||
let apply = |args: &[&str]| -> std::process::Output {
|
||
let mut c = common::pic_as(&env);
|
||
c.args(["apply", "--file"]).arg(&manifest).args(args);
|
||
c.output().expect("apply")
|
||
};
|
||
|
||
// production is confirm-required → a bare `--env production` is refused,
|
||
// and the message names `--approve`. (Client-side: never hits the server.)
|
||
let out = apply(&["--env", "production"]);
|
||
assert!(!out.status.success(), "production must require approval");
|
||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||
assert!(
|
||
err.contains("approve") && err.contains("production"),
|
||
"the refusal must point at --approve production:\n{err}"
|
||
);
|
||
|
||
// A blanket `--yes` does NOT cover a gated env.
|
||
assert!(
|
||
!apply(&["--env", "production", "--yes"]).status.success(),
|
||
"--yes must not bypass a confirm-required env"
|
||
);
|
||
|
||
// `--approve production` lets it through.
|
||
assert!(
|
||
apply(&["--env", "production", "--approve", "production"])
|
||
.status
|
||
.success(),
|
||
"an explicit --approve production must apply"
|
||
);
|
||
|
||
// staging is `confirm = false` → applies freely, no approval needed.
|
||
assert!(
|
||
apply(&["--env", "staging"]).status.success(),
|
||
"an un-gated env must apply without --approve"
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn an_env_listed_with_an_empty_policy_is_a_hard_error() {
|
||
// `production = {}` (no `confirm`) must FAIL to load, not silently parse as
|
||
// un-gated — you can't half-declare a gate. `confirm` is a required field.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eae-grp");
|
||
let project = common::unique_slug("eae-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
|
||
let dir = TempDir::new().unwrap();
|
||
let manifest = dir.path().join("picloud.toml");
|
||
fs::write(
|
||
&manifest,
|
||
format!(
|
||
"[project]\nslug = \"{project}\"\n\n\
|
||
[project.environments]\nproduction = {{}}\n\n\
|
||
[group]\nslug = \"{group}\"\nname = \"Empty Policy\"\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();
|
||
|
||
let out = common::pic_as(&env)
|
||
.args(["apply", "--file"])
|
||
.arg(&manifest)
|
||
.args(["--env", "production"])
|
||
.output()
|
||
.expect("apply");
|
||
assert!(
|
||
!out.status.success(),
|
||
"an env with an empty policy must be a load error, not a silent no-gate"
|
||
);
|
||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||
assert!(
|
||
err.contains("confirm"),
|
||
"the error must name the missing `confirm` field:\n{err}"
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn leaf_file_apply_honors_the_root_env_gate() {
|
||
// `pic apply --file <leaf>` where the leaf carries no `[project]` still
|
||
// honors the gate declared in the repo root (found by walking up).
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let base = common::unique_slug("eal-base");
|
||
let leaf = common::unique_slug("eal-leaf");
|
||
let project = common::unique_slug("eal-proj");
|
||
let _b = GroupGuard::new(&env.url, &env.token, &base);
|
||
let _l = GroupGuard::new(&env.url, &env.token, &leaf);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &base])
|
||
.assert()
|
||
.success();
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &leaf, "--parent", &base])
|
||
.assert()
|
||
.success();
|
||
|
||
// Root manifest carries the gating `[project]`; the leaf (in a subdir) has
|
||
// no `[project]` of its own and is applied on its own with `--file`.
|
||
let dir = TempDir::new().unwrap();
|
||
fs::write(
|
||
dir.path().join("picloud.toml"),
|
||
format!(
|
||
"[project]\nslug = \"{project}\"\n\n\
|
||
[project.environments]\nproduction = {{ confirm = true }}\n\n\
|
||
[group]\nslug = \"{base}\"\nname = \"Base\"\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
let sub = dir.path().join("sub");
|
||
fs::create_dir_all(&sub).unwrap();
|
||
let leaf_manifest = sub.join("picloud.toml");
|
||
fs::write(
|
||
&leaf_manifest,
|
||
format!("[group]\nslug = \"{leaf}\"\nname = \"Leaf\"\n"),
|
||
)
|
||
.unwrap();
|
||
fs::write(sub.join("picloud.production.toml"), "# overlay\n").unwrap();
|
||
|
||
// Without --approve: refused, because the gate is discovered up-tree.
|
||
let out = common::pic_as(&env)
|
||
.args(["apply", "--file"])
|
||
.arg(&leaf_manifest)
|
||
.args(["--env", "production"])
|
||
.output()
|
||
.expect("apply");
|
||
assert!(
|
||
!out.status.success(),
|
||
"a leaf --file apply must honor the root's env gate"
|
||
);
|
||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||
assert!(
|
||
err.contains("approve") && err.contains("production"),
|
||
"the refusal must point at --approve production:\n{err}"
|
||
);
|
||
|
||
// With --approve production: passes the gate and applies (leaf pre-exists).
|
||
common::pic_as(&env)
|
||
.args(["apply", "--file"])
|
||
.arg(&leaf_manifest)
|
||
.args(["--env", "production", "--approve", "production"])
|
||
.assert()
|
||
.success();
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn server_enforces_the_gate_against_a_non_stock_client() {
|
||
// The client-side gate is a convenience; a patched CLI or a raw request can
|
||
// skip it. The server re-derives the policy from the wire and enforces it:
|
||
// a raw tree apply to a confirm-required env WITHOUT the env in
|
||
// `approved_envs` is refused (409), regardless of the client.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eas-grp");
|
||
let project = common::unique_slug("eas-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
let http = reqwest::blocking::Client::new();
|
||
let body = |approved: serde_json::Value| {
|
||
serde_json::json!({
|
||
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||
"project": {
|
||
"slug": project,
|
||
"environments": { "production": { "confirm": true } }
|
||
},
|
||
"env": "production",
|
||
"approved_envs": approved,
|
||
"prune": false,
|
||
})
|
||
};
|
||
|
||
// No approval on the wire → 409 ApprovalRequired (server-enforced, the CLI's
|
||
// client-side gate never ran).
|
||
let resp = http
|
||
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&body(serde_json::json!([])))
|
||
.send()
|
||
.expect("tree apply");
|
||
assert_eq!(
|
||
resp.status().as_u16(),
|
||
409,
|
||
"a raw gated apply without approval must be refused server-side (got {}: {})",
|
||
resp.status(),
|
||
resp.text().unwrap_or_default()
|
||
);
|
||
|
||
// Approved on the wire + an admin token (holds GroupAdmin) → succeeds.
|
||
let ok = http
|
||
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&body(serde_json::json!(["production"])))
|
||
.send()
|
||
.expect("tree apply");
|
||
assert!(
|
||
ok.status().is_success(),
|
||
"an approved gated apply by an admin must succeed (got {}: {})",
|
||
ok.status(),
|
||
ok.text().unwrap_or_default()
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn approving_a_gated_apply_requires_admin() {
|
||
// §4.2: overriding a gate is ADMIN authority — a step up from the editor
|
||
// write caps an ordinary apply needs. A group `editor` who CAN write the
|
||
// group still cannot approve a gated apply of it.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eaa-grp");
|
||
let project = common::unique_slug("eaa-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
// A member granted `editor` (write, not admin) on the group.
|
||
let m = member::member_user(fx, &common::unique_username("eaa"));
|
||
common::pic_as(&env)
|
||
.args([
|
||
"groups", "members", "add", &group, &m.id, "--role", "editor",
|
||
])
|
||
.assert()
|
||
.success();
|
||
let member_env = common::custom_env(&fx.url, &m.token);
|
||
common::seed_credentials(&member_env, &m.username);
|
||
|
||
// A gated repo managing that group (with a var, so the editor's write cap is
|
||
// exercised — proving the admin gate is ABOVE it). Applied by the editor.
|
||
let dir = TempDir::new().unwrap();
|
||
fs::write(
|
||
dir.path().join("picloud.toml"),
|
||
format!(
|
||
"[project]\nslug = \"{project}\"\n\n\
|
||
[project.environments]\nproduction = {{ confirm = true }}\n\n\
|
||
[group]\nslug = \"{group}\"\nname = \"Gated\"\n\n\
|
||
[vars]\nregion = \"eu\"\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();
|
||
let manifest = dir.path().join("picloud.toml");
|
||
|
||
let out = common::pic_as(&member_env)
|
||
.args(["apply", "--file"])
|
||
.arg(&manifest)
|
||
.args(["--env", "production", "--approve", "production"])
|
||
.output()
|
||
.expect("apply");
|
||
assert!(
|
||
!out.status.success(),
|
||
"a non-admin editor must not be able to approve a gated apply"
|
||
);
|
||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||
assert!(
|
||
err.contains("forbidden") || err.contains("403"),
|
||
"the approval denial should be an authz error:\n{err}"
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn persisted_policy_gates_a_request_that_omits_it() {
|
||
// §3 M3 HERMETIC: once an apply persists a `confirm = true` env, a LATER raw
|
||
// request that OMITS the policy entirely (empty `environments`) is STILL
|
||
// gated — the server enforces against the persisted policy, not just the
|
||
// wire. This is the hand-crafted-request bypass the earlier CLI-only gate
|
||
// could not close.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eah-grp");
|
||
let project = common::unique_slug("eah-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
let http = reqwest::blocking::Client::new();
|
||
let apply = |environments: serde_json::Value, approved: serde_json::Value| {
|
||
http.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&serde_json::json!({
|
||
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||
"project": { "slug": project, "environments": environments },
|
||
"env": "production",
|
||
"approved_envs": approved,
|
||
"prune": false,
|
||
}))
|
||
.send()
|
||
.expect("tree apply")
|
||
};
|
||
|
||
// First apply DECLARES + APPROVES the gate → persists `production=true`.
|
||
let seed = apply(
|
||
serde_json::json!({ "production": { "confirm": true } }),
|
||
serde_json::json!(["production"]),
|
||
);
|
||
assert!(
|
||
seed.status().is_success(),
|
||
"the seeding apply (declared + approved) must succeed (got {}: {})",
|
||
seed.status(),
|
||
seed.text().unwrap_or_default()
|
||
);
|
||
|
||
// Now a request that OMITS the policy and does NOT approve → still 409,
|
||
// because the persisted policy still gates `production`.
|
||
let omit = apply(serde_json::json!({}), serde_json::json!([]));
|
||
assert_eq!(
|
||
omit.status().as_u16(),
|
||
409,
|
||
"a persisted gate must hold even when the request omits the policy (got {}: {})",
|
||
omit.status(),
|
||
omit.text().unwrap_or_default()
|
||
);
|
||
|
||
// Approving it clears the persisted gate (admin token holds GroupAdmin).
|
||
let ok = apply(serde_json::json!({}), serde_json::json!(["production"]));
|
||
assert!(
|
||
ok.status().is_success(),
|
||
"approving the persisted gate must let it through (got {}: {})",
|
||
ok.status(),
|
||
ok.text().unwrap_or_default()
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn omitting_the_project_block_entirely_does_not_bypass_a_persisted_gate() {
|
||
// Audit 2026-07-11 H1: the earlier gate keyed the persisted policy on the
|
||
// client-supplied `project.slug`, so a request that OMITTED `[project]`
|
||
// ENTIRELY (`project: null`, not just empty `environments`) yielded an empty
|
||
// policy and skipped the gate. The fix resolves the GOVERNING project
|
||
// server-side from the target node's nearest-claimed ancestor, so a request
|
||
// to a claimed, gated node can no longer apply just by dropping `[project]`.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eao-grp");
|
||
let project = common::unique_slug("eao-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
let http = reqwest::blocking::Client::new();
|
||
|
||
// Seed: declare + approve the gate → claims the group AND persists
|
||
// `production=true` on the owning project.
|
||
let seed = http
|
||
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&serde_json::json!({
|
||
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||
"project": {
|
||
"slug": project,
|
||
"environments": { "production": { "confirm": true } }
|
||
},
|
||
"env": "production",
|
||
"approved_envs": ["production"],
|
||
"prune": false,
|
||
}))
|
||
.send()
|
||
.expect("seed apply");
|
||
assert!(
|
||
seed.status().is_success(),
|
||
"the seeding apply (declared + approved) must succeed (got {}: {})",
|
||
seed.status(),
|
||
seed.text().unwrap_or_default()
|
||
);
|
||
|
||
// Attack: a raw apply that OMITS the `project` block entirely (no `project`
|
||
// key on the wire) to the same gated node, WITHOUT approving `production`.
|
||
// The server resolves the governing project from the node itself, so this is
|
||
// refused (409) — it does NOT quietly apply.
|
||
let omit = http
|
||
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&serde_json::json!({
|
||
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||
"env": "production",
|
||
"approved_envs": [],
|
||
"prune": false,
|
||
}))
|
||
.send()
|
||
.expect("omit apply");
|
||
assert_eq!(
|
||
omit.status().as_u16(),
|
||
409,
|
||
"omitting [project] must not bypass the persisted gate (got {}: {})",
|
||
omit.status(),
|
||
omit.text().unwrap_or_default()
|
||
);
|
||
}
|
||
|
||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||
#[test]
|
||
fn flipping_a_gate_to_false_in_the_same_request_still_gates() {
|
||
// §3 M3 HERMETIC (monotonic rule): the gate evaluates against
|
||
// `persisted ∪ declared`, so declaring `confirm = false` for an env a prior
|
||
// apply persisted as `true` does NOT un-gate it in the same request — the
|
||
// ungating apply must itself pass the gate. This closes the flip-to-false
|
||
// bypass.
|
||
let Some(fx) = common::fixture_or_skip() else {
|
||
return;
|
||
};
|
||
let env = common::admin_env(fx);
|
||
let group = common::unique_slug("eaf-grp");
|
||
let project = common::unique_slug("eaf-proj");
|
||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||
common::pic_as(&env)
|
||
.args(["groups", "create", &group])
|
||
.assert()
|
||
.success();
|
||
|
||
let http = reqwest::blocking::Client::new();
|
||
let apply = |environments: serde_json::Value, approved: serde_json::Value| {
|
||
http.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||
.bearer_auth(&env.token)
|
||
.json(&serde_json::json!({
|
||
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||
"project": { "slug": project, "environments": environments },
|
||
"env": "production",
|
||
"approved_envs": approved,
|
||
"prune": false,
|
||
}))
|
||
.send()
|
||
.expect("tree apply")
|
||
};
|
||
|
||
// Persist `production=true`.
|
||
assert!(apply(
|
||
serde_json::json!({ "production": { "confirm": true } }),
|
||
serde_json::json!(["production"]),
|
||
)
|
||
.status()
|
||
.is_success());
|
||
|
||
// Same request flips it to false but does NOT approve → still 409 (the
|
||
// persisted `true` wins under the union rule).
|
||
let flip = apply(
|
||
serde_json::json!({ "production": { "confirm": false } }),
|
||
serde_json::json!([]),
|
||
);
|
||
assert_eq!(
|
||
flip.status().as_u16(),
|
||
409,
|
||
"flipping a persisted gate to false must not un-gate it in the same request (got {}: {})",
|
||
flip.status(),
|
||
flip.text().unwrap_or_default()
|
||
);
|
||
}
|