diff --git a/Cargo.toml b/Cargo.toml index 7643558..13f4588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ tower_governor = "0.4" governor = "0.6" # Database -sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] } ipnetwork = "0.20" # Serialization diff --git a/migrations/20260718000001_operator_settings.sql b/migrations/20260718000001_operator_settings.sql new file mode 100644 index 0000000..42a3a76 --- /dev/null +++ b/migrations/20260718000001_operator_settings.sql @@ -0,0 +1,15 @@ +-- Operator-editable settings overlay: the DB precedence tier UNDER the env-derived +-- Config (effective = defaults -> env -> DB overlay). One JSONB row per section; the +-- effective Config is rebuilt from these at boot and on change. Each row is MAC'd with +-- ADMIN_KEY_INTEGRITY_SECRET (same anchor as server_config / admin_keys) so a DB-write +-- attacker cannot silently flip operator policy, and every write is appended to the +-- hash-chained admin_audit log. A missing section = env-only (no seed rows). Secrets are +-- NEVER stored here (overlays carry no secret fields by construction). +CREATE TABLE operator_settings ( + section TEXT PRIMARY KEY, + doc JSONB NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + updated_by UUID REFERENCES admin_keys(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + integrity_mac TEXT NOT NULL +); diff --git a/src/config_overlay.rs b/src/config_overlay.rs new file mode 100644 index 0000000..3fe189d --- /dev/null +++ b/src/config_overlay.rs @@ -0,0 +1,207 @@ +//! Operator-editable settings overlay — the DB precedence tier under the env-derived +//! [`Config`] (effective = defaults -> env -> DB overlay). A [`SettingsOverlay`] is a +//! SPARSE patch: one `Option<*Overlay>` per editable section, each a struct of +//! `Option` NON-SECRET fields (absent = "leave as env"). Secrets are excluded BY +//! CONSTRUCTION — no overlay field maps to a secret sub-struct — so the admin plane can +//! neither read nor set one. [`Config::apply_overlay`] merges an overlay onto the +//! env-derived config and re-runs the SAME [`Config::validate`] boot uses, so a bad edit +//! fails identically and nothing invalid is ever applied. Pure over `Config`. + +use serde::{Deserialize, Serialize}; + +use crate::config::{Config, RestorePolicy}; +use crate::error::AppError; + +/// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None` +/// (absent) section leaves that whole group at its env value. Unknown sections/fields +/// are ignored on load (forward-compat); the PUT path validates before persisting. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SettingsOverlay { + pub landing: Option, + pub retention: Option, + pub restore: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct LandingOverlay { + pub enabled: Option, + /// Empty string clears the title (env `None`); any other value sets it. + pub title: Option, + pub expose_version: Option, + pub expose_chains: Option, + pub expose_price_feed: Option, + pub expose_uptime: Option, + pub stats_enabled: Option, + pub stats_cache_hours: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RetentionOverlay { + pub login_events_days: Option, + pub audit_days: Option, + /// Gates a boot-spawned sweep worker -> RESTART-REQUIRED (see `runtime_class`). + pub erasure_enabled: Option, + pub purge_login_events: Option, + pub export_per_day: Option, + pub grace_period_hours: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RestoreOverlay { + /// One of `create-only` | `bounded` | `unlimited`. + pub policy: Option, + pub max_depth_days: Option, + pub pow_free_days: Option, + pub pow_days_per_bit: Option, + pub pow_max_bits: Option, +} + +/// Whether a changed field applies live or needs a restart. Drives BOTH the hot-swap +/// decision and the `GET /admin/config` annotation — the single source of truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeClass { + /// Re-read off the effective config on the next request; hot-swappable. + RuntimeSafe, + /// Captured by a boot-built client/socket/worker; applied on graceful restart. + RestartRequired, +} + +/// Classify one `(section, field)`. Unknown pairs default to `RestartRequired` (the +/// safe default: never hot-swap something whose consumers we haven't reasoned about). +pub fn runtime_class(section: &str, field: &str) -> RuntimeClass { + use RuntimeClass::*; + match (section, field) { + // erasure_enabled gates a boot-spawned sweep worker; a live flip would desync + // the worker from the request gate. + ("retention", "erasure_enabled") => RestartRequired, + ("landing", _) => RuntimeSafe, + ("retention", _) => RuntimeSafe, + ("restore", _) => RuntimeSafe, + _ => RestartRequired, + } +} + +impl Config { + /// Merge a settings overlay onto this (env-derived) config, then re-run the SAME + /// validation boot uses. Returns the candidate effective config, or the identical + /// [`AppError`] boot would raise — so a bad edit is rejected BEFORE anything + /// persists and can never brick the instance. Pure: does not mutate `self`. + pub fn apply_overlay(&self, ov: &SettingsOverlay) -> Result { + let mut c = self.clone(); + + if let Some(l) = &ov.landing { + if let Some(v) = l.enabled { + c.landing.enabled = v; + } + if let Some(v) = &l.title { + c.landing.title = if v.is_empty() { None } else { Some(v.clone()) }; + } + if let Some(v) = l.expose_version { + c.landing.expose_version = v; + } + if let Some(v) = l.expose_chains { + c.landing.expose_chains = v; + } + if let Some(v) = l.expose_price_feed { + c.landing.expose_price_feed = v; + } + if let Some(v) = l.expose_uptime { + c.landing.expose_uptime = v; + } + if let Some(v) = l.stats_enabled { + c.landing.stats_enabled = v; + } + if let Some(v) = l.stats_cache_hours { + c.landing.stats_cache_hours = v; + } + } + + if let Some(r) = &ov.retention { + if let Some(v) = r.login_events_days { + c.retention.login_events_days = v; + } + if let Some(v) = r.audit_days { + c.retention.audit_days = v; + } + if let Some(v) = r.erasure_enabled { + c.retention.erasure_enabled = v; + } + if let Some(v) = r.purge_login_events { + c.retention.purge_login_events = v; + } + if let Some(v) = r.export_per_day { + c.retention.export_per_day = v; + } + if let Some(v) = r.grace_period_hours { + c.retention.grace_period_hours = v; + } + } + + if let Some(r) = &ov.restore { + if let Some(p) = &r.policy { + c.restore.policy = match p.as_str() { + "create-only" => RestorePolicy::CreateOnly, + "bounded" => RestorePolicy::Bounded, + "unlimited" => RestorePolicy::Unlimited, + other => { + return Err(AppError::ValidationError(format!( + "invalid restore.policy {other:?} (want create-only|bounded|unlimited)" + ))) + } + }; + } + if let Some(v) = r.max_depth_days { + c.restore.max_depth_days = v; + } + if let Some(v) = r.pow_free_days { + c.restore.pow_free_days = v; + } + if let Some(v) = r.pow_days_per_bit { + c.restore.pow_days_per_bit = v; + } + if let Some(v) = r.pow_max_bits { + c.restore.pow_max_bits = v; + } + } + + c.validate()?; + Ok(c) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_parses_sparse_json_ignoring_absent_and_unknown_sections() { + // Absent sections stay None; unknown sections are ignored (forward-compat). + let ov: SettingsOverlay = serde_json::from_str( + r#"{"restore":{"policy":"unlimited"},"future_section":{"x":1}}"#, + ) + .expect("sparse overlay parses"); + assert!(ov.landing.is_none()); + assert!(ov.retention.is_none()); + assert_eq!(ov.restore.unwrap().policy.as_deref(), Some("unlimited")); + } + + #[test] + fn runtime_class_classifies_known_and_defaults_restart_for_unknown() { + assert_eq!(runtime_class("landing", "enabled"), RuntimeClass::RuntimeSafe); + assert_eq!( + runtime_class("restore", "max_depth_days"), + RuntimeClass::RuntimeSafe + ); + // erasure_enabled gates a boot worker; unknown pairs default restart-required. + assert_eq!( + runtime_class("retention", "erasure_enabled"), + RuntimeClass::RestartRequired + ); + assert_eq!(runtime_class("mystery", "field"), RuntimeClass::RestartRequired); + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 41456ab..e059953 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -15,6 +15,7 @@ mod erasure; mod grin_slatepacks; mod invite_codes; mod login_events; +mod operator_settings; mod payment_invoices; mod premium_invoices; mod restore_attempts; diff --git a/src/infra/db/operator_settings.rs b/src/infra/db/operator_settings.rs new file mode 100644 index 0000000..6a4bfc3 --- /dev/null +++ b/src/infra/db/operator_settings.rs @@ -0,0 +1,172 @@ +//! Operator settings overlay storage (`operator_settings`) — the DB tier under the +//! env-derived Config. One MAC'd JSONB row per section (`doc` = that section's sparse +//! patch). Loads verify the MAC per section (fail-closed: a tampered/undecodable +//! section is ignored and falls back to env); writes upsert + append to the +//! hash-chained admin_audit log in one transaction. Mirrors `server_config` (MAC) and +//! `admin_keys::create_admin_key_audited` (audited write). + +use chrono::{SubsecRound, Utc}; +use serde_json::Value; +use subtle::ConstantTimeEq; +use tracing::{instrument, warn}; +use uuid::Uuid; + +use crate::config_overlay::SettingsOverlay; +use crate::core::crypto::pepper::peppered_hex; +use crate::error::AppError; +use crate::models::db::{NewAdminAudit, OperatorSettingRow}; + +use super::Database; + +/// Recursively key-sorted, whitespace-free JSON so the MAC is stable across Postgres +/// JSONB normalization (JSONB does NOT preserve object key order). Both write and read +/// canonicalize the same way, so the MAC round-trips regardless of storage reordering. +fn canonical_json(v: &Value) -> String { + match v { + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let inner: Vec = keys + .into_iter() + .map(|k| { + format!( + "{}:{}", + Value::String(k.clone()), + canonical_json(&map[k]) + ) + }) + .collect(); + format!("{{{}}}", inner.join(",")) + } + Value::Array(arr) => { + let inner: Vec = arr.iter().map(canonical_json).collect(); + format!("[{}]", inner.join(",")) + } + other => other.to_string(), + } +} + +/// `HMAC(secret, "operator_settings" ‖ section ‖ version ‖ canonical(doc))`. +fn settings_mac(secret: &str, section: &str, version: i64, doc: &Value) -> String { + peppered_hex( + secret, + "operator_settings", + &format!("{section}\u{1f}{version}\u{1f}{}", canonical_json(doc)), + ) +} + +fn verify_settings_mac(secret: &str, row: &OperatorSettingRow) -> bool { + let expected = settings_mac(secret, &row.section, row.version, &row.doc); + expected + .as_bytes() + .ct_eq(row.integrity_mac.as_bytes()) + .into() +} + +impl Database { + /// Load + verify all settings rows into a [`SettingsOverlay`]. A row whose MAC + /// fails, or whose section fails to deserialize, is IGNORED (that section falls + /// back to env) and logged — never fails the caller. Fail-closed on tamper, + /// fail-open-to-env on a bad/absent section. + #[instrument(skip(self, secret))] + pub async fn load_settings_overlay(&self, secret: &str) -> Result { + let rows = sqlx::query_as::<_, OperatorSettingRow>( + "SELECT section, doc, version, updated_by, updated_at, integrity_mac \ + FROM operator_settings", + ) + .fetch_all(self.pool()) + .await?; + + let mut merged = serde_json::Map::new(); + for row in &rows { + if !verify_settings_mac(secret, row) { + warn!(section = %row.section, "operator_settings MAC mismatch; ignoring section (env-only)"); + continue; + } + merged.insert(row.section.clone(), row.doc.clone()); + } + match serde_json::from_value::(Value::Object(merged)) { + Ok(ov) => Ok(ov), + Err(e) => { + warn!(error = %e, "operator_settings overlay decode failed; using env-only"); + Ok(SettingsOverlay::default()) + } + } + } + + /// Upsert one section's `doc` (new MAC + bumped version) and append the audit entry + /// in a single transaction. Returns the new version. The caller MUST have already + /// validated the resulting effective config via `Config::apply_overlay` (this only + /// persists). `FOR UPDATE` serializes concurrent writers to the same section. + #[instrument(skip(self, doc, audit, secret))] + pub async fn put_setting_audited( + &self, + section: &str, + doc: &Value, + updated_by: Option, + audit: &NewAdminAudit, + secret: &str, + ) -> Result { + let mut tx = self.pool().begin().await?; + + let current: Option = sqlx::query_scalar( + "SELECT version FROM operator_settings WHERE section = $1 FOR UPDATE", + ) + .bind(section) + .fetch_optional(&mut *tx) + .await?; + let version = current.unwrap_or(0) + 1; + let mac = settings_mac(secret, section, version, doc); + let updated_at = Utc::now().trunc_subsecs(6); + + sqlx::query( + "INSERT INTO operator_settings \ + (section, doc, version, updated_by, updated_at, integrity_mac) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (section) DO UPDATE SET \ + doc = EXCLUDED.doc, version = EXCLUDED.version, \ + updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at, \ + integrity_mac = EXCLUDED.integrity_mac", + ) + .bind(section) + .bind(doc) + .bind(version) + .bind(updated_by) + .bind(updated_at) + .bind(&mac) + .execute(&mut *tx) + .await?; + + self.append_admin_audit(&mut *tx, audit, secret).await?; + tx.commit().await?; + Ok(version) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_json_is_key_order_independent() { + let a = json!({"b": 1, "a": {"y": 2, "x": [3, 2]}}); + let b = json!({"a": {"x": [3, 2], "y": 2}, "b": 1}); + assert_eq!(canonical_json(&a), canonical_json(&b)); + } + + #[test] + fn settings_mac_changes_with_version_and_doc() { + let doc = json!({"enabled": true}); + let m1 = settings_mac("secret-secret-secret-secret-32ch", "landing", 1, &doc); + let m2 = settings_mac("secret-secret-secret-secret-32ch", "landing", 2, &doc); + let m3 = settings_mac( + "secret-secret-secret-secret-32ch", + "landing", + 1, + &json!({"enabled": false}), + ); + assert_ne!(m1, m2); + assert_ne!(m1, m3); + } +} diff --git a/src/lib.rs b/src/lib.rs index af59c1d..b0446e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod api; pub mod config; +pub mod config_overlay; pub mod core; pub mod error; pub mod infra; diff --git a/src/models/db.rs b/src/models/db.rs index 5d6c252..0ce12f5 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -307,6 +307,19 @@ pub struct ServerConfig { pub integrity_mac: String, } +/// A row of the operator-editable settings overlay (`operator_settings`). One row +/// per config section; `doc` is that section's sparse JSON patch over env. MAC'd +/// with ADMIN_KEY_INTEGRITY_SECRET + audited on every write. Holds no secrets. +#[derive(Debug, Clone, FromRow)] +pub struct OperatorSettingRow { + pub section: String, + pub doc: serde_json::Value, + pub version: i64, + pub updated_by: Option, + pub updated_at: DateTime, + pub integrity_mac: String, +} + /// A row in the tamper-evident privileged-action audit chain. #[derive(Debug, Clone, FromRow)] pub struct AdminAuditLog {