mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod config_overlay;
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
pub mod infra;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user