use std::str::FromStr; use anyhow::{Context, Result}; use serde_json::Value; use sqlx::PgPool; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; 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); } if config.enforce_strict_tls && !matches!( options.get_ssl_mode(), PgSslMode::VerifyCa | PgSslMode::VerifyFull ) { anyhow::bail!( "Refusing to start in production with weak PostgreSQL TLS mode. \ Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full." ); } Ok(options) } pub async fn create_pool(config: &DatabaseConfig) -> Result { tracing::debug!("connecting to database"); let connect_options = build_connect_options(config)?; let pool = PgPoolOptions::new() .max_connections(10) .acquire_timeout(std::time::Duration::from_secs(5)) .connect_with(connect_options) .await?; tracing::debug!("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() ); -- Legacy unique constraint without user_id (single-user mode) 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); -- ── 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 ''; -- ── 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; CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user ON entries(user_id, folder, name) WHERE user_id IS NOT 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_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; "#, ) .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(()) } // ── DB helpers ────────────────────────────────────────────────────────────────