- audit_log 查询去掉 detail->>'user_id' 回退分支 - login_detail 不再冗余写入 user_id 到 detail JSON - 迁移 SQL 去掉多余的 ALTER TABLE ADD COLUMN Made-with: Cursor
238 lines
10 KiB
Rust
238 lines
10 KiB
Rust
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<PgPool> {
|
|
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,
|
|
user_id UUID,
|
|
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);
|
|
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,
|
|
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<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 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 ────────────────────────────────────────────────────────────────
|