use anyhow::Result; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; pub async fn create_pool(database_url: &str) -> Result { tracing::debug!("connecting to database"); let pool = PgPoolOptions::new() .max_connections(5) .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#" CREATE TABLE IF NOT EXISTS secrets ( id UUID PRIMARY KEY DEFAULT uuidv7(), 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 '{}', 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(namespace, kind, name) ); -- idempotent column add for existing tables DO $$ BEGIN ALTER TABLE secrets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'; EXCEPTION WHEN OTHERS THEN NULL; END $$; DO $$ BEGIN ALTER TABLE secrets ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1; EXCEPTION WHEN OTHERS THEN NULL; END $$; -- Migrate encrypted column from JSONB to BYTEA if still JSONB type. -- After migration, old plaintext rows will have their JSONB data -- stored as raw bytes (UTF-8 encoded). DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'secrets' AND column_name = 'encrypted' AND data_type = 'jsonb' ) THEN ALTER TABLE secrets RENAME COLUMN encrypted TO encrypted_jsonb_old; ALTER TABLE secrets ADD COLUMN encrypted BYTEA NOT NULL DEFAULT '\x'; -- Copy existing JSONB data as raw UTF-8 bytes so nothing is lost UPDATE secrets SET encrypted = convert_to(encrypted_jsonb_old::text, 'UTF8'); ALTER TABLE secrets DROP COLUMN encrypted_jsonb_old; END IF; EXCEPTION WHEN OTHERS THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace); CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind); CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops); -- Key-value config table: stores Argon2id salt (shared across devices) CREATE TABLE IF NOT EXISTS kv_config ( key TEXT PRIMARY KEY, value BYTEA NOT NULL ); 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); -- History table: snapshot of secrets before each write operation. -- Supports rollback to any prior version via `secrets rollback`. CREATE TABLE IF NOT EXISTS secrets_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, secret_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 '{}', encrypted BYTEA NOT NULL DEFAULT '\x', actor VARCHAR(128) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_history_secret_id ON secrets_history(secret_id, version DESC); CREATE INDEX IF NOT EXISTS idx_history_ns_kind_name ON secrets_history(namespace, kind, name, version DESC); "#, ) .execute(pool) .await?; tracing::debug!("migrations complete"); Ok(()) } /// Snapshot parameters grouped to avoid too-many-arguments lint. pub struct SnapshotParams<'a> { pub secret_id: uuid::Uuid, 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 serde_json::Value, pub encrypted: &'a [u8], } /// Snapshot a secrets row into `secrets_history` before a write operation. /// `action` is one of "add", "update", "delete". /// Failures are non-fatal (caller should warn). pub async fn snapshot_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, p: SnapshotParams<'_>, ) -> Result<()> { let actor = std::env::var("USER").unwrap_or_default(); sqlx::query( "INSERT INTO secrets_history \ (secret_id, namespace, kind, name, version, action, tags, metadata, encrypted, actor) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(p.secret_id) .bind(p.namespace) .bind(p.kind) .bind(p.name) .bind(p.version) .bind(p.action) .bind(p.tags) .bind(p.metadata) .bind(p.encrypted) .bind(&actor) .execute(&mut **tx) .await?; Ok(()) } /// Load the Argon2id salt from the database. /// Returns None if not yet initialized. pub async fn load_argon2_salt(pool: &PgPool) -> Result>> { let row: Option<(Vec,)> = sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'") .fetch_optional(pool) .await?; Ok(row.map(|(v,)| v)) } /// Store the Argon2id salt in the database (only called once on first device init). pub async fn store_argon2_salt(pool: &PgPool, salt: &[u8]) -> Result<()> { sqlx::query( "INSERT INTO kv_config (key, value) VALUES ('argon2_salt', $1) \ ON CONFLICT (key) DO NOTHING", ) .bind(salt) .execute(pool) .await?; Ok(()) }