feat(config): operator settings overlay foundation (storage + validated merge)

Stage 1 of the operator-editable config layer (additive; no behavior change yet):
- migration operator_settings: one MAC(ADMIN_KEY_INTEGRITY_SECRET) JSONB row per
  section, FK to admin_keys, audited on write. Missing section = env-only.
- OperatorSettingRow model.
- SettingsOverlay + section overlays (landing/retention/restore) with secrets
  EXCLUDED BY CONSTRUCTION; Config::apply_overlay merges onto the env-derived Config
  then re-runs the SAME Config::validate boot uses (a bad edit yields the identical
  error, nothing persists, the instance cannot be bricked).
- runtime_class() single source of truth (RuntimeSafe hot-swap vs RestartRequired).
- operator_settings storage: canonical-JSON MAC (stable across JSONB key reorder),
  load_settings_overlay (fail-closed per section -> env fallback), put_setting_audited
  (upsert + admin_audit append in one tx).
- sqlx json feature.

cargo check clean; 4 new unit tests pass. Next stages: ArcSwap AppState + ~59
read-site migration, GET/PUT /admin/config (+ restart_apply_mode), remaining sections.
This commit is contained in:
jwinterm
2026-07-18 04:33:20 -04:00
parent ede7bec4a6
commit 00968a9c05
7 changed files with 410 additions and 1 deletions
+207
View File
@@ -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<T>` 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<LandingOverlay>,
pub retention: Option<RetentionOverlay>,
pub restore: Option<RestoreOverlay>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LandingOverlay {
pub enabled: Option<bool>,
/// Empty string clears the title (env `None`); any other value sets it.
pub title: Option<String>,
pub expose_version: Option<bool>,
pub expose_chains: Option<bool>,
pub expose_price_feed: Option<bool>,
pub expose_uptime: Option<bool>,
pub stats_enabled: Option<bool>,
pub stats_cache_hours: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RetentionOverlay {
pub login_events_days: Option<u64>,
pub audit_days: Option<u64>,
/// Gates a boot-spawned sweep worker -> RESTART-REQUIRED (see `runtime_class`).
pub erasure_enabled: Option<bool>,
pub purge_login_events: Option<bool>,
pub export_per_day: Option<u32>,
pub grace_period_hours: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RestoreOverlay {
/// One of `create-only` | `bounded` | `unlimited`.
pub policy: Option<String>,
pub max_depth_days: Option<u32>,
pub pow_free_days: Option<u32>,
pub pow_days_per_bit: Option<u32>,
pub pow_max_bits: Option<u32>,
}
/// 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<Config, AppError> {
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);
}
}
+1
View File
@@ -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;
+172
View File
@@ -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<String> = keys
.into_iter()
.map(|k| {
format!(
"{}:{}",
Value::String(k.clone()),
canonical_json(&map[k])
)
})
.collect();
format!("{{{}}}", inner.join(","))
}
Value::Array(arr) => {
let inner: Vec<String> = 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<SettingsOverlay, AppError> {
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::<SettingsOverlay>(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<Uuid>,
audit: &NewAdminAudit,
secret: &str,
) -> Result<i64, AppError> {
let mut tx = self.pool().begin().await?;
let current: Option<i64> = 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);
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod api;
pub mod config;
pub mod config_overlay;
pub mod core;
pub mod error;
pub mod infra;
+13
View File
@@ -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<Uuid>,
pub updated_at: DateTime<Utc>,
pub integrity_mac: String,
}
/// A row in the tamper-evident privileged-action audit chain.
#[derive(Debug, Clone, FromRow)]
pub struct AdminAuditLog {