Files
smirk-backend-core/src/infra/db/mod.rs
T
jwinterm 00968a9c05 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.
2026-07-18 04:33:20 -04:00

93 lines
2.7 KiB
Rust

//! Database access layer.
//!
//! `Database` wraps the connection pool and the secrets needed to pepper
//! identity values at rest. Per-entity query methods are implemented on it in
//! sibling modules (`users`, ...). Identity values (`pubkey_hash`,
//! `seed_fingerprint`) are peppered inside these methods so the plaintext never
//! reaches a column and the peppering cannot drift between call sites.
mod admin_audit;
mod admin_keys;
mod admin_sessions;
mod audit;
mod challenges;
mod erasure;
mod grin_slatepacks;
mod invite_codes;
mod login_events;
mod operator_settings;
mod payment_invoices;
mod premium_invoices;
mod restore_attempts;
mod server_config;
mod sessions;
mod social_tips;
mod user_keys;
mod users;
pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome};
pub use challenges::ConsumedChallenge;
pub use login_events::LoginStats;
pub use payment_invoices::PaymentInvoiceRow;
pub use premium_invoices::PremiumInvoiceRow;
pub use server_config::SetupState;
pub use social_tips::{GcCancelledTip, NewSocialTip, SocialTipRow, StuckClaimingTip};
use sqlx::PgPool;
use crate::core::crypto::pepper::peppered_hex;
use crate::error::AppError;
/// Connection pool + at-rest peppering secrets. Does not derive `Debug`.
#[derive(Clone)]
pub struct Database {
pool: PgPool,
/// HMAC pepper for identity columns (pubkey_hash, seed_fingerprint).
identity_pepper: String,
/// Salt for hashing client IPs in analytics/abuse tables.
ip_salt: String,
}
impl Database {
pub fn new(pool: PgPool, identity_pepper: String, ip_salt: String) -> Self {
Self {
pool,
identity_pepper,
ip_salt,
}
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
/// Salted hash of a client IP for analytics/abuse tables (never store raw).
pub(crate) fn hash_ip(&self, ip: &str) -> String {
peppered_hex(&self.ip_salt, "ip", ip)
}
/// Pepper an identity value for storage/lookup (see [`peppered_hex`]).
fn pepper(&self, domain: &str, value: &str) -> String {
peppered_hex(&self.identity_pepper, domain, value)
}
/// Liveness check against the database.
pub async fn health_check(&self) -> Result<bool, AppError> {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&self.pool)
.await?;
Ok(true)
}
}
/// Map a unique-constraint violation to a 409 CONFLICT with `msg`; pass other
/// errors through. Shared by the entity query modules.
pub(crate) fn unique_violation_as(msg: &'static str) -> impl Fn(sqlx::Error) -> AppError {
move |e| match &e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
AppError::Conflict(msg.to_string())
}
_ => AppError::from(e),
}
}