From 7c6b4d3ac15babf7116a08a496bd718b6f9a91ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 26 Jun 2026 17:50:36 +1200 Subject: [PATCH] feat(backups): per-config opt-out of the retention floor The org retention floor (keep_daily 7 / weekly 4 / monthly 6) is great for the common case, but some backups are taken for data processing we're not authorised to keep beyond a few days. Drop the floor as an unconditional rule: it stays the default, but each retention config can opt out. Adds an allow_below_floor flag to backup_type_defaults and server_group_backup_schedule. When set, the floor is neither validated on write (set_type_default / set_schedule) nor enforced when the policy is resolved for kopia (resolve_policy). The flag travels with the winning retention source through resolution, so a dangerous override or dangerous default exempts its own type; everything else still gets the floor. Both retention UIs (the canopy-wide defaults editor and the per-(group,type) override editor) gain a 'dangerous' toggle that drops the floor validation and input minimums, with a warning, and a 'below floor' chip on the summary. Co-Authored-By: Claude Opus 4.8 --- crates/commons-servers/src/backup_jobs.rs | 80 ++++++++++++++----- crates/database/src/backups.rs | 13 ++- crates/database/src/schema.rs | 2 + crates/database/tests/backup_detection.rs | 1 + crates/database/tests/backups.rs | 4 + crates/private-server/src/fns/backups.rs | 38 ++++++++- crates/private-server/tests/backups.rs | 52 ++++++++++++ .../down.sql | 2 + .../up.sql | 9 +++ private-web/e2e/backups.spec.ts | 38 +++++++++ private-web/e2e/settings.spec.ts | 36 +++++++++ private-web/openapi.json | 33 ++++++-- private-web/src/api-types.ts | 19 ++++- private-web/src/routes/BackupDefaults.tsx | 44 ++++++++-- private-web/src/routes/BackupPanel.tsx | 43 ++++++++-- 15 files changed, 370 insertions(+), 44 deletions(-) create mode 100644 migrations/2026-06-26-055046-0000_backup_retention_below_floor/down.sql create mode 100644 migrations/2026-06-26-055046-0000_backup_retention_below_floor/up.sql diff --git a/crates/commons-servers/src/backup_jobs.rs b/crates/commons-servers/src/backup_jobs.rs index ff85990c..707c8656 100644 --- a/crates/commons-servers/src/backup_jobs.rs +++ b/crates/commons-servers/src/backup_jobs.rs @@ -92,22 +92,32 @@ impl RetentionPolicy { } /// Merge a per-`(group, type)` retention override with the type's default and -/// the org floor: the schedule override JSON wins; else the type default JSON; -/// else (or on parse error) the floor baseline. The result always has the floor -/// enforced. Pure so the precedence/floor logic is unit-testable without a DB. -fn resolve_policy(override_json: Option, default_json: Option) -> RetentionPolicy { - let json = override_json.or(default_json); - match json.map(serde_json::from_value::) { - Some(Ok(policy)) => policy.enforce_floor(), - _ => RetentionPolicy::floor_baseline(), +/// the org floor: the schedule override wins; else the type default; else (or on +/// parse error) the floor baseline. The floor is enforced on the result **unless** +/// the winning source opted out (`allow_below_floor`, a dangerous per-config +/// toggle for backups we're not authorised to keep). Each source is an +/// `(json, allow_below_floor)` pair. Pure so the precedence/floor logic is +/// unit-testable without a DB. +fn resolve_policy( + override_: Option<(Value, bool)>, + default: Option<(Value, bool)>, +) -> RetentionPolicy { + match override_.or(default) { + Some((json, allow_below_floor)) => match serde_json::from_value::(json) { + Ok(policy) if allow_below_floor => policy, + Ok(policy) => policy.enforce_floor(), + Err(_) => RetentionPolicy::floor_baseline(), + }, + None => RetentionPolicy::floor_baseline(), } } /// Resolve the effective retention policy for each backup type **declared** in -/// the group (not just enabled): schedule override → type default → org floor, -/// with the floor always enforced. Returns one `(type, policy)` pair per declared -/// type — so a manual backup of a non-scheduled (disabled) type is still retained -/// under its own type policy rather than only the repo's global baseline. +/// the group (not just enabled): schedule override → type default → org floor. +/// The floor is enforced unless the winning source set `allow_below_floor`. +/// Returns one `(type, policy)` pair per declared type — so a manual backup of a +/// non-scheduled (disabled) type is still retained under its own type policy +/// rather than only the repo's global baseline. pub async fn effective_retention_for_group( db: &mut AsyncPgConnection, group_id: Uuid, @@ -115,13 +125,13 @@ pub async fn effective_retention_for_group( let types = ServerBackupCapability::declared_types_for_group(db, group_id).await?; let mut out = Vec::with_capacity(types.len()); for ty in types { - let override_json = ServerGroupBackupSchedule::get(db, group_id, &ty) + let override_ = ServerGroupBackupSchedule::get(db, group_id, &ty) .await? - .and_then(|s| s.retention); - let default_json = BackupTypeDefault::get(db, &ty) + .and_then(|s| s.retention.map(|r| (r, s.allow_below_floor))); + let default = BackupTypeDefault::get(db, &ty) .await? - .map(|d| d.default_retention); - out.push((ty, resolve_policy(override_json, default_json))); + .map(|d| (d.default_retention, d.allow_below_floor)); + out.push((ty, resolve_policy(override_, default))); } Ok(out) } @@ -489,13 +499,13 @@ mod tests { fn resolve_policy_precedence_and_floor() { // Override wins over default. let p = resolve_policy( - Some(serde_json::json!({"keep_daily": 30})), - Some(serde_json::json!({"keep_daily": 10})), + Some((serde_json::json!({"keep_daily": 30}), false)), + Some((serde_json::json!({"keep_daily": 10}), false)), ); assert_eq!(p.keep_daily, 30, "override wins"); // No override → default applies. - let p = resolve_policy(None, Some(serde_json::json!({"keep_monthly": 12}))); + let p = resolve_policy(None, Some((serde_json::json!({"keep_monthly": 12}), false))); assert_eq!(p.keep_monthly, 12, "default fallback"); assert_eq!(p.keep_daily, 7, "floor still enforced on default"); @@ -508,17 +518,43 @@ mod tests { // Below-floor override → clamped up to the floor. let p = resolve_policy( - Some(serde_json::json!({"keep_daily": 2, "keep_weekly": 1})), + Some(( + serde_json::json!({"keep_daily": 2, "keep_weekly": 1}), + false, + )), None, ); assert_eq!(p.keep_daily, 7, "clamped up"); assert_eq!(p.keep_weekly, 4, "clamped up"); // Garbage JSON → floor baseline (parse error path). - let p = resolve_policy(Some(serde_json::json!("not a policy")), None); + let p = resolve_policy(Some((serde_json::json!("not a policy"), false)), None); assert_eq!(p.keep_daily, 7); } + #[test] + fn resolve_policy_allow_below_floor_skips_floor() { + // A dangerous override below the floor is preserved verbatim, not clamped. + let p = resolve_policy( + Some((serde_json::json!({"keep_daily": 2, "keep_weekly": 0}), true)), + None, + ); + assert_eq!(p.keep_daily, 2, "below-floor preserved"); + assert_eq!(p.keep_weekly, 0, "below-floor preserved"); + + // A dangerous default is also exempt when it's the winning source. + let p = resolve_policy(None, Some((serde_json::json!({"keep_daily": 1}), true))); + assert_eq!(p.keep_daily, 1, "dangerous default exempt"); + + // The override's flag governs — a dangerous default doesn't exempt a + // non-dangerous override (the override is the winning source). + let p = resolve_policy( + Some((serde_json::json!({"keep_daily": 3}), false)), + Some((serde_json::json!({"keep_daily": 1}), true)), + ); + assert_eq!(p.keep_daily, 7, "non-dangerous override still floored"); + } + #[test] fn retention_json_roundtrip_defaults() { // keep_latest defaults to 1, others to 0, when absent. diff --git a/crates/database/src/backups.rs b/crates/database/src/backups.rs index e21a94d7..4c5d85a3 100644 --- a/crates/database/src/backups.rs +++ b/crates/database/src/backups.rs @@ -36,7 +36,8 @@ use crate::pg_duration::PgDuration; /// kopia `keep-*` retention policy. Org-minimum floors /// (`keep_daily ≥ 7, keep_weekly ≥ 4, keep_monthly ≥ 6`) are enforced by -/// [`RetentionPolicy::validate_floor`] on create/update. +/// [`RetentionPolicy::validate_floor`] on create/update — unless the config +/// opts out via its `allow_below_floor` flag (dangerous). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct RetentionPolicy { #[serde(default = "RetentionPolicy::default_keep_latest")] @@ -355,6 +356,9 @@ pub struct BackupTypeDefault { pub default_interval: Option, pub default_retention: JsonValue, pub auto_enable: bool, + /// Opt out of the org retention floor for this type's default (dangerous): + /// the floor is neither validated on write nor enforced on resolve. + pub allow_below_floor: bool, } #[derive(Debug, Clone, Insertable)] @@ -366,6 +370,7 @@ pub struct NewBackupTypeDefault { pub default_interval: Option, pub default_retention: JsonValue, pub auto_enable: bool, + pub allow_below_floor: bool, } impl BackupTypeDefault { @@ -401,6 +406,7 @@ impl BackupTypeDefault { dsl::default_interval.eq(new.default_interval), dsl::default_retention.eq(&new.default_retention), dsl::auto_enable.eq(new.auto_enable), + dsl::allow_below_floor.eq(new.allow_below_floor), )) .returning(Self::as_select()) .get_result(db) @@ -570,6 +576,9 @@ pub struct ServerGroupBackupSchedule { pub created_at: Timestamp, #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] pub updated_at: Timestamp, + /// Opt out of the org retention floor for this override (dangerous): the + /// floor is neither validated on write nor enforced on resolve. + pub allow_below_floor: bool, } #[derive(Debug, Clone, Insertable)] @@ -581,6 +590,7 @@ pub struct NewServerGroupBackupSchedule { pub r#type: BackupType, pub expected_interval: Option, pub retention: Option, + pub allow_below_floor: bool, } impl ServerGroupBackupSchedule { @@ -624,6 +634,7 @@ impl ServerGroupBackupSchedule { .set(( dsl::expected_interval.eq(new.expected_interval), dsl::retention.eq(&new.retention), + dsl::allow_below_floor.eq(new.allow_below_floor), dsl::updated_at.eq(now), )) .returning(Self::as_select()) diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index 38552e2c..de81559b 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -123,6 +123,7 @@ diesel::table! { default_interval -> Nullable, default_retention -> Jsonb, auto_enable -> Bool, + allow_below_floor -> Bool, } } @@ -351,6 +352,7 @@ diesel::table! { retention -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, + allow_below_floor -> Bool, } } diff --git a/crates/database/tests/backup_detection.rs b/crates/database/tests/backup_detection.rs index b174a142..04808392 100644 --- a/crates/database/tests/backup_detection.rs +++ b/crates/database/tests/backup_detection.rs @@ -120,6 +120,7 @@ async fn insert_schedule( r#type: ty.clone(), expected_interval: Some(PgDuration(interval)), retention: Some(retention()), + allow_below_floor: false, }, ) .await diff --git a/crates/database/tests/backups.rs b/crates/database/tests/backups.rs index 38e1f981..5d1cddb9 100644 --- a/crates/database/tests/backups.rs +++ b/crates/database/tests/backups.rs @@ -138,6 +138,7 @@ async fn type_defaults_retention_must_be_object() { default_interval: Some(PgDuration(SignedDuration::from_hours(24))), default_retention: retention(), auto_enable: true, + allow_below_floor: false, }, ) .await @@ -153,6 +154,7 @@ async fn type_defaults_retention_must_be_object() { default_interval: None, default_retention: serde_json::json!([1, 2, 3]), auto_enable: false, + allow_below_floor: false, }, ) .await; @@ -706,6 +708,7 @@ async fn schedule_upsert_and_get() { r#type: pg.clone(), expected_interval: Some(PgDuration(SignedDuration::from_hours(12))), retention: Some(retention()), + allow_below_floor: false, }, ) .await @@ -729,6 +732,7 @@ async fn schedule_upsert_and_get() { r#type: pg.clone(), expected_interval: None, retention: None, + allow_below_floor: false, }, ) .await diff --git a/crates/private-server/src/fns/backups.rs b/crates/private-server/src/fns/backups.rs index 5dc3f59b..1477986c 100644 --- a/crates/private-server/src/fns/backups.rs +++ b/crates/private-server/src/fns/backups.rs @@ -84,6 +84,8 @@ pub struct ScheduleView { #[schema(value_type = Option, format = "int64")] pub expected_interval: Option, pub retention: Option, + /// Whether this override opts out of the org retention floor (dangerous). + pub allow_below_floor: bool, } /// Full config + lifecycle for a group. Never includes the passphrase value. @@ -122,6 +124,7 @@ impl BackupConfigView { r#type: s.r#type, expected_interval: s.expected_interval, retention: s.retention.as_ref().and_then(RetentionPolicy::from_json), + allow_below_floor: s.allow_below_floor, }) .collect(); Ok(Self { @@ -217,8 +220,13 @@ pub struct SetScheduleArgs { /// Seconds; None = manual-only (no schedule), distinct from 0. #[schema(value_type = Option, format = "int64")] pub expected_interval: Option, - /// None = inherit the type default. A present policy is floor-validated. + /// None = inherit the type default. A present policy is floor-validated + /// unless `allow_below_floor` is set. pub retention: Option, + /// Dangerous: opt this override out of the org retention floor, allowing a + /// retention smaller than the org minimum. Defaults false. + #[serde(default)] + pub allow_below_floor: bool, } #[derive(Deserialize, ToSchema)] @@ -851,7 +859,9 @@ pub async fn set_schedule( let mut conn = state.db.get().await?; require_config(&mut conn, args.server_group_id).await?; if let Some(policy) = &args.retention { - policy.validate_floor()?; + if !args.allow_below_floor { + policy.validate_floor()?; + } } ServerGroupBackupSchedule::upsert( &mut conn, @@ -860,6 +870,7 @@ pub async fn set_schedule( r#type: args.r#type, expected_interval: args.expected_interval, retention: args.retention.map(|r| r.to_json()), + allow_below_floor: args.allow_below_floor, }, ) .await?; @@ -925,6 +936,9 @@ pub struct GroupTypeScheduleView { pub effective_interval: Option, pub effective_retention: RetentionPolicy, pub has_override: bool, + /// Whether the effective config opts out of the org retention floor — taken + /// from the override if present, else the type default. + pub allow_below_floor: bool, /// When the next scheduled backup of this type is expected: the group's most /// recent successful backup of the type plus the interval — or "now" if the /// type is scheduled but has never succeeded yet. Null for manual-only types. @@ -989,6 +1003,14 @@ pub async fn group_schedules( .and_then(|d| RetentionPolicy::from_json(&d.default_retention)) }) .unwrap_or(FLOOR_RETENTION); + // Mirror the retention precedence: the override's flag governs when it + // supplies the effective retention, else the type default's. + let allow_below_floor = over + .as_ref() + .filter(|s| s.retention.is_some()) + .map(|s| s.allow_below_floor) + .or_else(|| def.as_ref().map(|d| d.allow_below_floor)) + .unwrap_or(false); // Scheduled types: latest success + interval (or now if never run yet). // Manual-only types (no interval) have no expected next run. let next_run_at = effective_interval.map(|secs| { @@ -1001,6 +1023,7 @@ pub async fn group_schedules( r#type: ty, effective_interval, effective_retention, + allow_below_floor, has_override: over.is_some(), next_run_at, }); @@ -1018,6 +1041,8 @@ pub struct TypeDefaultView { pub default_interval: Option, pub default_retention: Option, pub auto_enable: bool, + /// Whether this default opts out of the org retention floor (dangerous). + pub allow_below_floor: bool, } /// List the canopy-wide per-type defaults (the "global" schedule/retention each @@ -1043,6 +1068,7 @@ pub async fn type_defaults( default_interval: d.default_interval.map(|pg| pg.0.as_secs()), default_retention: RetentionPolicy::from_json(&d.default_retention), auto_enable: d.auto_enable, + allow_below_floor: d.allow_below_floor, }) .collect(), )) @@ -1059,6 +1085,9 @@ pub struct SetTypeDefaultArgs { pub default_retention: RetentionPolicy, #[serde(default)] pub auto_enable: bool, + /// Dangerous: opt this default out of the org retention floor. Defaults false. + #[serde(default)] + pub allow_below_floor: bool, } /// Set the canopy-wide default schedule/retention for a backup type @@ -1077,7 +1106,9 @@ pub async fn set_type_default( _admin: TailscaleAdmin, Json(args): Json, ) -> Result> { - args.default_retention.validate_floor()?; + if !args.allow_below_floor { + args.default_retention.validate_floor()?; + } let mut conn = state.db.get().await?; BackupTypeDefault::upsert( &mut conn, @@ -1086,6 +1117,7 @@ pub async fn set_type_default( default_interval: args.default_interval, default_retention: args.default_retention.to_json(), auto_enable: args.auto_enable, + allow_below_floor: args.allow_below_floor, }, ) .await?; diff --git a/crates/private-server/tests/backups.rs b/crates/private-server/tests/backups.rs index 74875d84..accab218 100644 --- a/crates/private-server/tests/backups.rs +++ b/crates/private-server/tests/backups.rs @@ -164,6 +164,28 @@ async fn set_schedule_floor_rejected_and_accepted() { assert_eq!(sched["type"], "tamanu-postgres"); assert_eq!(sched["expected_interval"], 3600); assert_eq!(sched["retention"]["keep_daily"], 7); + assert_eq!(sched["allow_below_floor"], false); + + // Below floor but allow_below_floor → accepted, and the dangerous flag + // round-trips into the view. + let resp = private + .post("/api/backups/set_schedule") + .json(&serde_json::json!({ + "server_group_id": group_id, + "type": "tamanu-postgres", + "expected_interval": 3600, + "retention": { + "keep_latest": 1, "keep_daily": 2, "keep_weekly": 0, + "keep_monthly": 0, "keep_annual": 0 + }, + "allow_below_floor": true, + })) + .await; + resp.assert_status_ok(); + let body: serde_json::Value = resp.json(); + let sched = &body["schedules"][0]; + assert_eq!(sched["retention"]["keep_daily"], 2); + assert_eq!(sched["allow_below_floor"], true); }) .await; } @@ -934,6 +956,8 @@ async fn type_defaults_list_and_set_roundtrip() { assert_eq!(td["default_interval"], 7200); assert_eq!(td["auto_enable"], false); + assert_eq!(td["allow_below_floor"], false); + // Below-floor retention → 400. private .post("/api/backups/set_type_default") @@ -947,6 +971,34 @@ async fn type_defaults_list_and_set_roundtrip() { })) .await .assert_status_bad_request(); + + // Below-floor retention with allow_below_floor → accepted, flag persisted. + private + .post("/api/backups/set_type_default") + .json(&serde_json::json!({ + "type": "tamanu-postgres", + "default_interval": null, + "default_retention": { + "keep_latest": 1, "keep_daily": 1, "keep_weekly": 0, + "keep_monthly": 0, "keep_annual": 0 + }, + "allow_below_floor": true, + })) + .await + .assert_status_ok(); + let resp = private + .post("/api/backups/type_defaults") + .json(&serde_json::json!({})) + .await; + let body: serde_json::Value = resp.json(); + let td = body + .as_array() + .unwrap() + .iter() + .find(|d| d["type"] == "tamanu-postgres") + .unwrap(); + assert_eq!(td["allow_below_floor"], true); + assert_eq!(td["default_retention"]["keep_daily"], 1); }) .await; } diff --git a/migrations/2026-06-26-055046-0000_backup_retention_below_floor/down.sql b/migrations/2026-06-26-055046-0000_backup_retention_below_floor/down.sql new file mode 100644 index 00000000..f2fb18a4 --- /dev/null +++ b/migrations/2026-06-26-055046-0000_backup_retention_below_floor/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE server_group_backup_schedule DROP COLUMN allow_below_floor; +ALTER TABLE backup_type_defaults DROP COLUMN allow_below_floor; diff --git a/migrations/2026-06-26-055046-0000_backup_retention_below_floor/up.sql b/migrations/2026-06-26-055046-0000_backup_retention_below_floor/up.sql new file mode 100644 index 00000000..8251232a --- /dev/null +++ b/migrations/2026-06-26-055046-0000_backup_retention_below_floor/up.sql @@ -0,0 +1,9 @@ +-- Per-config opt-out of the org retention floor. When true, the floor +-- (keep_daily 7 / weekly 4 / monthly 6) is neither validated on write nor +-- enforced when the policy is resolved — for backups taken for processing that +-- we're not authorised to keep beyond a few days. Defaults false (floor applies). +ALTER TABLE backup_type_defaults + ADD COLUMN allow_below_floor BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE server_group_backup_schedule + ADD COLUMN allow_below_floor BOOLEAN NOT NULL DEFAULT false; diff --git a/private-web/e2e/backups.spec.ts b/private-web/e2e/backups.spec.ts index 4f663875..a4b65433 100644 --- a/private-web/e2e/backups.spec.ts +++ b/private-web/e2e/backups.spec.ts @@ -213,6 +213,44 @@ test.describe("backups ready: stats + backup-now", () => { expect(Number(rows[0]!.secs)).toBe(43200); }); + test("override editor dangerous toggle allows retention below the floor", async ({ + page, + sql, + }) => { + const group = await seedServerGroup(sql, { name: "danger-group" }); + const server = await seedServer(sql, { groupId: group.id }); + await seedServerBackupCapability(sql, { serverId: server.id }); + await seedServerGroupBackupConfig(sql, { + groupId: group.id, + status: "ready", + }); + + await page.goto(`/groups/${group.id}/backups`); + await page.getByRole("button", { name: /^override$/i }).click(); + + // Below-floor daily is blocked until the dangerous toggle is on. + await page.getByLabel("Daily").fill("2"); + await expect(page.getByText(/daily must be ≥ 7/i)).toBeVisible(); + await page + .getByLabel(/allow retention below the org minimum/i) + .check(); + await page.getByRole("button", { name: /save override/i }).click(); + + await expect(page.getByText("below floor")).toBeVisible(); + await expect + .poll(async () => { + const rows = await sql.query<{ allow: boolean; keep_daily: string }>( + `SELECT allow_below_floor AS allow, + (retention->>'keep_daily') AS keep_daily + FROM server_group_backup_schedule + WHERE group_id = $1 AND type = 'tamanu-postgres'`, + [group.id], + ); + return rows[0] ? `${rows[0].allow}:${rows[0].keep_daily}` : null; + }) + .toBe("true:2"); + }); + test("stats render with unknown bucket bytes and recent runs", async ({ page, sql, diff --git a/private-web/e2e/settings.spec.ts b/private-web/e2e/settings.spec.ts index a9347606..59542f51 100644 --- a/private-web/e2e/settings.spec.ts +++ b/private-web/e2e/settings.spec.ts @@ -66,6 +66,42 @@ test.describe("Settings", () => { await expect(page.getByTestId("type-default-tamanu-files")).toBeVisible(); }); + test("backup defaults dangerous toggle allows retention below the floor", async ({ + page, + sql, + }) => { + await page.goto("/settings/backup-defaults"); + const card = page.getByTestId("type-default-tamanu-postgres"); + await expect(card).toBeVisible(); + + // A below-floor daily without the toggle blocks saving. + await card.getByLabel("Daily").fill("1"); + await expect( + card.getByText(/daily must be ≥ 7/i), + ).toBeVisible(); + await expect(card.getByRole("button", { name: /^save$/i })).toBeDisabled(); + + // Flip the dangerous toggle → the floor no longer blocks; save persists it. + await card + .getByLabel(/allow retention below the org minimum/i) + .check(); + await card.getByRole("button", { name: /^save$/i }).click(); + + await expect + .poll(async () => { + const rows = await sql.query<{ + allow: boolean; + keep_daily: string; + }>( + `SELECT allow_below_floor AS allow, + (default_retention->>'keep_daily') AS keep_daily + FROM backup_type_defaults WHERE type = 'tamanu-postgres'`, + ); + return rows[0] ? `${rows[0].allow}:${rows[0].keep_daily}` : null; + }) + .toBe("true:1"); + }); + test("backup defaults editor blocks adding a duplicate type", async ({ page, }) => { diff --git a/private-web/openapi.json b/private-web/openapi.json index 410ad000..e4adee74 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -6425,9 +6425,14 @@ "required": [ "type", "effective_retention", - "has_override" + "has_override", + "allow_below_floor" ], "properties": { + "allow_below_floor": { + "type": "boolean", + "description": "Whether the effective config opts out of the org retention floor — taken\nfrom the override if present, else the type default." + }, "effective_interval": { "type": [ "integer", @@ -8193,7 +8198,7 @@ }, "RetentionPolicy": { "type": "object", - "description": "kopia `keep-*` retention policy. Org-minimum floors\n(`keep_daily ≥ 7, keep_weekly ≥ 4, keep_monthly ≥ 6`) are enforced by\n[`RetentionPolicy::validate_floor`] on create/update.", + "description": "kopia `keep-*` retention policy. Org-minimum floors\n(`keep_daily ≥ 7, keep_weekly ≥ 4, keep_monthly ≥ 6`) are enforced by\n[`RetentionPolicy::validate_floor`] on create/update — unless the config\nopts out via its `allow_below_floor` flag (dangerous).", "required": [ "keep_daily", "keep_weekly", @@ -8274,9 +8279,14 @@ "type": "object", "description": "Per-`(group,type)` schedule + retention override. `expected_interval` None =\nmanual-only (distinct from 0). `retention` None = inherit the type default.", "required": [ - "type" + "type", + "allow_below_floor" ], "properties": { + "allow_below_floor": { + "type": "boolean", + "description": "Whether this override opts out of the org retention floor (dangerous)." + }, "expected_interval": { "type": [ "integer", @@ -9045,6 +9055,10 @@ "type" ], "properties": { + "allow_below_floor": { + "type": "boolean", + "description": "Dangerous: opt this override out of the org retention floor, allowing a\nretention smaller than the org minimum. Defaults false." + }, "expected_interval": { "type": [ "integer", @@ -9060,7 +9074,7 @@ }, { "$ref": "#/components/schemas/RetentionPolicy", - "description": "None = inherit the type default. A present policy is floor-validated." + "description": "None = inherit the type default. A present policy is floor-validated\nunless `allow_below_floor` is set." } ] }, @@ -9080,6 +9094,10 @@ "default_retention" ], "properties": { + "allow_below_floor": { + "type": "boolean", + "description": "Dangerous: opt this default out of the org retention floor. Defaults false." + }, "auto_enable": { "type": "boolean" }, @@ -9539,9 +9557,14 @@ "description": "Canopy-wide default schedule/retention for a backup type.", "required": [ "type", - "auto_enable" + "auto_enable", + "allow_below_floor" ], "properties": { + "allow_below_floor": { + "type": "boolean", + "description": "Whether this default opts out of the org retention floor (dangerous)." + }, "auto_enable": { "type": "boolean" }, diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 7b07dda2..8b3807d0 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -2592,6 +2592,11 @@ export interface components { * `has_override` tells the UI whether it's inheriting or overriding. */ GroupTypeScheduleView: { + /** + * @description Whether the effective config opts out of the org retention floor — taken + * from the override if present, else the type default. + */ + allow_below_floor: boolean; /** * Format: int64 * @description Seconds between scheduled runs; null = manual-only (no scheduled interval). @@ -3312,7 +3317,8 @@ export interface components { /** * @description kopia `keep-*` retention policy. Org-minimum floors * (`keep_daily ≥ 7, keep_weekly ≥ 4, keep_monthly ≥ 6`) are enforced by - * [`RetentionPolicy::validate_floor`] on create/update. + * [`RetentionPolicy::validate_floor`] on create/update — unless the config + * opts out via its `allow_below_floor` flag (dangerous). */ RetentionPolicy: { /** Format: int32 */ @@ -3350,6 +3356,8 @@ export interface components { * manual-only (distinct from 0). `retention` None = inherit the type default. */ ScheduleView: { + /** @description Whether this override opts out of the org retention floor (dangerous). */ + allow_below_floor: boolean; /** Format: int64 */ expected_interval?: number | null; retention?: null | components["schemas"]["RetentionPolicy"]; @@ -3621,6 +3629,11 @@ export interface components { type: string; }; SetScheduleArgs: { + /** + * @description Dangerous: opt this override out of the org retention floor, allowing a + * retention smaller than the org minimum. Defaults false. + */ + allow_below_floor?: boolean; /** * Format: int64 * @description Seconds; None = manual-only (no schedule), distinct from 0. @@ -3632,6 +3645,8 @@ export interface components { type: string; }; SetTypeDefaultArgs: { + /** @description Dangerous: opt this default out of the org retention floor. Defaults false. */ + allow_below_floor?: boolean; auto_enable?: boolean; /** * Format: int64 @@ -3820,6 +3835,8 @@ export interface components { }; /** @description Canopy-wide default schedule/retention for a backup type. */ TypeDefaultView: { + /** @description Whether this default opts out of the org retention floor (dangerous). */ + allow_below_floor: boolean; auto_enable: boolean; /** * Format: int64 diff --git a/private-web/src/routes/BackupDefaults.tsx b/private-web/src/routes/BackupDefaults.tsx index 48c7767b..5661ff22 100644 --- a/private-web/src/routes/BackupDefaults.tsx +++ b/private-web/src/routes/BackupDefaults.tsx @@ -28,6 +28,7 @@ type TypeDefault = { default_interval: number | null; default_retention: Retention | null; auto_enable: boolean; + allow_below_floor: boolean; }; const FLOOR_RETENTION: Retention = { @@ -53,6 +54,7 @@ const BLANK_DEFAULT: TypeDefault = { default_interval: 6 * 3600, default_retention: FLOOR_RETENTION, auto_enable: false, + allow_below_floor: false, }; /// Canopy-wide per-type backup defaults (`backup_type_defaults`): the schedule + @@ -131,13 +133,18 @@ function TypeDefaultEditor({ const [retention, setRetention] = useState( value.default_retention ?? FLOOR_RETENTION, ); + const [allowBelowFloor, setAllowBelowFloor] = useState( + value.allow_below_floor, + ); const trimmedType = typeName.trim(); const duplicate = creating && existingTypes.includes(trimmedType); - const floorError = RETENTION_FIELDS.filter( - (f) => f.floor != null && retention[f.key] < f.floor, - ).map((f) => `${f.label} must be ≥ ${f.floor}`); + const floorError = allowBelowFloor + ? [] + : RETENTION_FIELDS.filter( + (f) => f.floor != null && retention[f.key] < f.floor, + ).map((f) => `${f.label} must be ≥ ${f.floor}`); const canSave = !save.pending && @@ -150,6 +157,7 @@ function TypeDefaultEditor({ default_interval: scheduled ? Math.max(1, Number(hours)) * 3600 : null, default_retention: retention, auto_enable: autoEnable, + allow_below_floor: allowBelowFloor, }); onSaved(); }; @@ -213,13 +221,37 @@ function TypeDefaultEditor({ setRetention({ ...retention, [f.key]: Number(e.target.value) }) } disabled={save.pending} - error={f.floor != null && retention[f.key] < f.floor} - helperText={f.floor != null ? `≥ ${f.floor}` : undefined} - slotProps={{ htmlInput: { min: f.floor ?? 0, step: 1 } }} + error={ + !allowBelowFloor && f.floor != null && retention[f.key] < f.floor + } + helperText={ + !allowBelowFloor && f.floor != null ? `≥ ${f.floor}` : undefined + } + slotProps={{ + htmlInput: { min: allowBelowFloor ? 0 : (f.floor ?? 0), step: 1 }, + }} sx={{ width: 100 }} /> ))} + setAllowBelowFloor(e.target.checked)} + disabled={save.pending} + color="error" + /> + } + label="Allow retention below the org minimum (dangerous)" + /> + {allowBelowFloor && ( + + Snapshots of this type may be pruned below the org-minimum + retention. Only use this for data you are not authorised to keep + longer. + + )} + {schedule.allow_below_floor && ( + + )} {isAdmin && !editing && (