use std::str::FromStr; use anyhow::{Context, Result}; use serde_json::{Map, Value}; use sqlx::PgPool; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use crate::config::DatabaseConfig; fn build_connect_options(config: &DatabaseConfig) -> Result { let mut options = PgConnectOptions::from_str(&config.url) .with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?; if let Some(mode) = config.ssl_mode { options = options.ssl_mode(mode); } if let Some(path) = &config.ssl_root_cert { options = options.ssl_root_cert(path); } Ok(options) } pub async fn create_pool(config: &DatabaseConfig) -> Result { tracing::debug!("connecting to database"); let connect_options = build_connect_options(config)?; // Connection pool configuration from environment let max_connections = std::env::var("SECRETS_DATABASE_POOL_SIZE") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(10); let acquire_timeout_secs = std::env::var("SECRETS_DATABASE_ACQUIRE_TIMEOUT") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(5); let pool = PgPoolOptions::new() .max_connections(max_connections) .acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs)) .max_lifetime(std::time::Duration::from_secs(1800)) // 30 minutes .idle_timeout(std::time::Duration::from_secs(600)) // 10 minutes .connect_with(connect_options) .await?; tracing::debug!( max_connections, acquire_timeout_secs, "database connection established" ); Ok(pool) } pub async fn migrate(pool: &PgPool) -> Result<()> { tracing::debug!("running migrations"); sqlx::raw_sql( r#" -- ── entries: top-level entities ───────────────────────────────────────── CREATE TABLE IF NOT EXISTS entries ( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id UUID, folder VARCHAR(128) NOT NULL DEFAULT '', type VARCHAR(64) NOT NULL DEFAULT '', name VARCHAR(256) NOT NULL, notes TEXT NOT NULL DEFAULT '', tags TEXT[] NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}', version BIGINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); -- Legacy unique constraint without user_id (single-user mode) -- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support. CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy ON entries(folder, name) WHERE user_id IS NULL; -- Multi-user unique constraint CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user ON entries(user_id, folder, name) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_entries_folder ON entries(folder) WHERE folder <> ''; CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type) WHERE type <> ''; CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops); -- ── secrets: one row per encrypted field ───────────────────────────────── CREATE TABLE IF NOT EXISTS secrets ( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id UUID, name VARCHAR(256) NOT NULL, type VARCHAR(64) NOT NULL DEFAULT 'text', encrypted BYTEA NOT NULL DEFAULT '\x', version BIGINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name ON secrets(user_id, name) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type); -- ── entry_secrets: N:N relation ──────────────────────────────────────────── CREATE TABLE IF NOT EXISTS entry_secrets ( entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(entry_id, secret_id) ); CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id); -- ── entry_relations: parent-child links between entries ────────────────── CREATE TABLE IF NOT EXISTS entry_relations ( parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(parent_entry_id, child_entry_id), CHECK (parent_entry_id <> child_entry_id) ); CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id); CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id); -- ── audit_log: append-only operation log ───────────────────────────────── CREATE TABLE IF NOT EXISTS audit_log ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id UUID, action VARCHAR(32) NOT NULL, folder VARCHAR(128) NOT NULL DEFAULT '', type VARCHAR(64) NOT NULL DEFAULT '', name VARCHAR(256) NOT NULL, detail JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type); CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL; -- ── entries_history ─────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS entries_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entry_id UUID NOT NULL, folder VARCHAR(128) NOT NULL DEFAULT '', type VARCHAR(64) NOT NULL DEFAULT '', name VARCHAR(256) NOT NULL, version BIGINT NOT NULL, action VARCHAR(16) NOT NULL, tags TEXT[] NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id ON entries_history(entry_id, version DESC); CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name ON entries_history(folder, type, name, version DESC); -- Backfill: add user_id to entries_history for multi-tenant isolation ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID; CREATE INDEX IF NOT EXISTS idx_entries_history_user_id ON entries_history(user_id) WHERE user_id IS NOT NULL; ALTER TABLE entries_history DROP COLUMN IF EXISTS actor; -- Backfill: add notes to entries if not present (fresh installs already have it) ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT ''; ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- ── secrets_history: field-level snapshot ──────────────────────────────── CREATE TABLE IF NOT EXISTS secrets_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, secret_id UUID NOT NULL, name VARCHAR(256) NOT NULL, encrypted BYTEA NOT NULL DEFAULT '\x', action VARCHAR(16) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id ON secrets_history(secret_id); -- Drop redundant actor column (derivable via entries_history JOIN) ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; -- ── users ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuidv7(), email VARCHAR(256), name VARCHAR(256) NOT NULL DEFAULT '', avatar_url TEXT, key_salt BYTEA, key_check BYTEA, key_params JSONB, api_key TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ── oauth_accounts: per-provider identity links ─────────────────────────── CREATE TABLE IF NOT EXISTS oauth_accounts ( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider VARCHAR(32) NOT NULL, provider_id VARCHAR(256) NOT NULL, email VARCHAR(256), name VARCHAR(256), avatar_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(provider, provider_id) ); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts(user_id, provider); -- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL) DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id' ) THEN ALTER TABLE entries ADD CONSTRAINT fk_entries_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id' ) THEN ALTER TABLE entries_history ADD CONSTRAINT fk_entries_history_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id' ) THEN ALTER TABLE secrets ADD CONSTRAINT fk_secrets_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id' ) THEN ALTER TABLE audit_log ADD CONSTRAINT fk_audit_log_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; END IF; END $$; "#, ) .execute(pool) .await?; migrate_schema(pool).await?; restore_plaintext_api_keys(pool).await?; tracing::debug!("migrations complete"); Ok(()) } /// Idempotent schema migration: rename namespace→folder, kind→type in existing databases. async fn migrate_schema(pool: &PgPool) -> Result<()> { sqlx::raw_sql( r#" -- ── entries: rename namespace→folder, kind→type ────────────────────────── DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries' AND column_name = 'namespace' ) THEN ALTER TABLE entries RENAME COLUMN namespace TO folder; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries' AND column_name = 'kind' ) THEN ALTER TABLE entries RENAME COLUMN kind TO type; END IF; END $$; -- ── audit_log: rename namespace→folder, kind→type ──────────────────────── DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'audit_log' AND column_name = 'namespace' ) THEN ALTER TABLE audit_log RENAME COLUMN namespace TO folder; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'audit_log' AND column_name = 'kind' ) THEN ALTER TABLE audit_log RENAME COLUMN kind TO type; END IF; END $$; -- ── entries_history: rename namespace→folder, kind→type ────────────────── DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries_history' AND column_name = 'namespace' ) THEN ALTER TABLE entries_history RENAME COLUMN namespace TO folder; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries_history' AND column_name = 'kind' ) THEN ALTER TABLE entries_history RENAME COLUMN kind TO type; END IF; END $$; -- ── Set empty defaults for new folder/type columns ──────────────────────── DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries' AND column_name = 'folder' ) THEN UPDATE entries SET folder = '' WHERE folder IS NULL; ALTER TABLE entries ALTER COLUMN folder SET NOT NULL; ALTER TABLE entries ALTER COLUMN folder SET DEFAULT ''; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries' AND column_name = 'type' ) THEN UPDATE entries SET type = '' WHERE type IS NULL; ALTER TABLE entries ALTER COLUMN type SET NOT NULL; ALTER TABLE entries ALTER COLUMN type SET DEFAULT ''; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'audit_log' AND column_name = 'folder' ) THEN UPDATE audit_log SET folder = '' WHERE folder IS NULL; ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL; ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT ''; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'audit_log' AND column_name = 'type' ) THEN UPDATE audit_log SET type = '' WHERE type IS NULL; ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL; ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT ''; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries_history' AND column_name = 'folder' ) THEN UPDATE entries_history SET folder = '' WHERE folder IS NULL; ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL; ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT ''; END IF; END $$; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'entries_history' AND column_name = 'type' ) THEN UPDATE entries_history SET type = '' WHERE type IS NULL; ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL; ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT ''; END IF; END $$; -- ── Rebuild unique indexes on entries: folder is now part of the key ──────── -- (user_id, folder, name) allows same name in different folders. DROP INDEX IF EXISTS idx_entries_unique_legacy; DROP INDEX IF EXISTS idx_entries_unique_user; CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy ON entries(folder, name) WHERE user_id IS NULL AND deleted_at IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user ON entries(user_id, folder, name) WHERE user_id IS NOT NULL AND deleted_at IS NULL; -- ── Replace old namespace/kind indexes ──────────────────────────────────── DROP INDEX IF EXISTS idx_entries_namespace; DROP INDEX IF EXISTS idx_entries_kind; DROP INDEX IF EXISTS idx_audit_log_ns_kind; DROP INDEX IF EXISTS idx_entries_history_ns_kind_name; CREATE INDEX IF NOT EXISTS idx_entries_folder ON entries(folder) WHERE folder <> ''; CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type) WHERE type <> ''; CREATE INDEX IF NOT EXISTS idx_entries_deleted_at ON entries(deleted_at) WHERE deleted_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type); CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name ON entries_history(folder, type, name, version DESC); -- ── Drop legacy actor columns ───────────────────────────────────────────── ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; ALTER TABLE audit_log DROP COLUMN IF EXISTS actor; -- ── key_version: incremented on passphrase change to invalidate other sessions ── ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0; "#, ) .execute(pool) .await?; Ok(()) } async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> { let has_users_api_key: bool = sqlx::query_scalar( "SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'api_key' )", ) .fetch_one(pool) .await?; if !has_users_api_key { sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT") .execute(pool) .await?; sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL") .execute(pool) .await?; } let has_api_keys_table: bool = sqlx::query_scalar( "SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'api_keys' )", ) .fetch_one(pool) .await?; if !has_api_keys_table { return Ok(()); } #[derive(sqlx::FromRow)] struct UserWithoutKey { id: uuid::Uuid, } let users_without_key: Vec = sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)") .fetch_all(pool) .await?; for user in users_without_key { let new_key = crate::service::api_key::generate_api_key(); sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") .bind(&new_key) .bind(user.id) .execute(pool) .await?; } sqlx::query("DROP TABLE IF EXISTS api_keys") .execute(pool) .await?; Ok(()) } // ── Entry-level history snapshot ───────────────────────────────────────────── pub struct EntrySnapshotParams<'a> { pub entry_id: uuid::Uuid, pub user_id: Option, pub folder: &'a str, pub entry_type: &'a str, pub name: &'a str, pub version: i64, pub action: &'a str, pub tags: &'a [String], pub metadata: &'a Value, } pub async fn snapshot_entry_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, p: EntrySnapshotParams<'_>, ) -> Result<()> { sqlx::query( "INSERT INTO entries_history \ (entry_id, folder, type, name, version, action, tags, metadata, user_id) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) .bind(p.entry_id) .bind(p.folder) .bind(p.entry_type) .bind(p.name) .bind(p.version) .bind(p.action) .bind(p.tags) .bind(p.metadata) .bind(p.user_id) .execute(&mut **tx) .await?; Ok(()) } // ── Secret field-level history snapshot ────────────────────────────────────── pub struct SecretSnapshotParams<'a> { pub secret_id: uuid::Uuid, pub name: &'a str, pub encrypted: &'a [u8], pub action: &'a str, } pub async fn snapshot_secret_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, p: SecretSnapshotParams<'_>, ) -> Result<()> { sqlx::query( "INSERT INTO secrets_history \ (secret_id, name, encrypted, action) \ VALUES ($1, $2, $3, $4)", ) .bind(p.secret_id) .bind(p.name) .bind(p.encrypted) .bind(p.action) .execute(&mut **tx) .await?; Ok(()) } pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct EntrySecretSnapshot { pub name: String, #[serde(rename = "type")] pub secret_type: String, pub encrypted_hex: String, } pub async fn metadata_with_secret_snapshot( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, entry_id: uuid::Uuid, metadata: &Value, ) -> Result { #[derive(sqlx::FromRow)] struct Row { name: String, #[sqlx(rename = "type")] secret_type: String, encrypted: Vec, } let rows: Vec = sqlx::query_as( "SELECT s.name, s.type, s.encrypted \ FROM entry_secrets es \ JOIN secrets s ON s.id = es.secret_id \ WHERE es.entry_id = $1 \ ORDER BY s.name ASC", ) .bind(entry_id) .fetch_all(&mut **tx) .await?; let snapshots: Vec = rows .into_iter() .map(|r| EntrySecretSnapshot { name: r.name, secret_type: r.secret_type, encrypted_hex: ::hex::encode(r.encrypted), }) .collect(); let mut merged = match metadata.clone() { Value::Object(obj) => obj, _ => Map::new(), }; merged.insert( ENTRY_HISTORY_SECRETS_KEY.to_string(), serde_json::to_value(snapshots)?, ); Ok(Value::Object(merged)) } pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value { let mut m = match metadata.clone() { Value::Object(obj) => obj, _ => return metadata.clone(), }; m.remove(ENTRY_HISTORY_SECRETS_KEY); Value::Object(m) } pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option> { let Value::Object(map) = metadata else { return None; }; let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?; serde_json::from_value(raw.clone()).ok() } // ── DB helpers ────────────────────────────────────────────────────────────────