Bump version: secrets-mcp-0.5.1 tag already existed while crates had further changes. Made-with: Cursor
566 lines
22 KiB
Rust
566 lines
22 KiB
Rust
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<PgConnectOptions> {
|
|
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<PgPool> {
|
|
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::<u32>().ok())
|
|
.unwrap_or(10);
|
|
|
|
let acquire_timeout_secs = std::env::var("SECRETS_DATABASE_ACQUIRE_TIMEOUT")
|
|
.ok()
|
|
.and_then(|v| v.parse::<u64>().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()
|
|
);
|
|
|
|
-- 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<UserWithoutKey> =
|
|
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<uuid::Uuid>,
|
|
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 ────────────────────────────────────────────────────────────────
|