use anyhow::Result; use serde_json::Value; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; use crate::audit::current_actor; pub async fn create_pool(database_url: &str) -> Result { tracing::debug!("connecting to database"); let pool = PgPoolOptions::new() .max_connections(10) .acquire_timeout(std::time::Duration::from_secs(5)) .connect(database_url) .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, namespace VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL, name VARCHAR(256) NOT NULL, 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(namespace, kind, name) WHERE user_id IS NULL; -- Multi-user unique constraint CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user ON entries(user_id, namespace, kind, name) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace); CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind); 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(), entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, field_name VARCHAR(256) NOT NULL, 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(), UNIQUE(entry_id, field_name) ); CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id); -- ── audit_log: append-only operation log ───────────────────────────────── CREATE TABLE IF NOT EXISTS audit_log ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, action VARCHAR(32) NOT NULL, namespace VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL, name VARCHAR(256) NOT NULL, detail JSONB NOT NULL DEFAULT '{}', actor VARCHAR(128) 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_ns_kind ON audit_log(namespace, kind); -- ── entries_history ─────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS entries_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entry_id UUID NOT NULL, namespace VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL, name VARCHAR(256) NOT NULL, version BIGINT NOT NULL, action VARCHAR(16) NOT NULL, tags TEXT[] NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}', actor VARCHAR(128) 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_ns_kind_name ON entries_history(namespace, kind, 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; -- ── secrets_history: field-level snapshot ──────────────────────────────── CREATE TABLE IF NOT EXISTS secrets_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entry_id UUID NOT NULL, secret_id UUID NOT NULL, entry_version BIGINT NOT NULL, field_name VARCHAR(256) NOT NULL, encrypted BYTEA NOT NULL DEFAULT '\x', action VARCHAR(16) NOT NULL, actor VARCHAR(128) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id ON secrets_history(entry_id, entry_version DESC); CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id ON secrets_history(secret_id); -- ── 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); "#, ) .execute(pool) .await?; tracing::debug!("migrations complete"); Ok(()) } // ── Entry-level history snapshot ───────────────────────────────────────────── pub struct EntrySnapshotParams<'a> { pub entry_id: uuid::Uuid, pub user_id: Option, pub namespace: &'a str, pub kind: &'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<()> { let actor = current_actor(); sqlx::query( "INSERT INTO entries_history \ (entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(p.entry_id) .bind(p.namespace) .bind(p.kind) .bind(p.name) .bind(p.version) .bind(p.action) .bind(p.tags) .bind(p.metadata) .bind(&actor) .bind(p.user_id) .execute(&mut **tx) .await?; Ok(()) } // ── Secret field-level history snapshot ────────────────────────────────────── pub struct SecretSnapshotParams<'a> { pub entry_id: uuid::Uuid, pub secret_id: uuid::Uuid, pub entry_version: i64, pub field_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<()> { let actor = current_actor(); sqlx::query( "INSERT INTO secrets_history \ (entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \ VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(p.entry_id) .bind(p.secret_id) .bind(p.entry_version) .bind(p.field_name) .bind(p.encrypted) .bind(p.action) .bind(&actor) .execute(&mut **tx) .await?; Ok(()) } // ── DB helpers ────────────────────────────────────────────────────────────────