refactor: entries + secrets 双表,search 展示 field schema,key_ref PEM 共享
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- secrets 表拆为 entries(主表)+ secrets(每字段一行) - search 无需 master_key 即可展示 secrets 字段名、类型、长度 - inject/run 支持 metadata.key_ref 引用 kind=key 记录,PEM 轮换 O(1) - entries_history + secrets_history 字段级历史,rollback 按 version 恢复 - 移除迁移用 DROP 语句,migrate 幂等 - v0.8.0 Made-with: Cursor
This commit is contained in:
164
src/db.rs
164
src/db.rs
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
@@ -17,61 +18,48 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
tracing::debug!("running migrations");
|
||||
sqlx::raw_sql(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
-- ── entries: top-level entities (server, service, key, …) ──────────────
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
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 $$;
|
||||
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_tags ON entries USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
-- ── secrets: one row per encrypted field, plaintext schema metadata ────
|
||||
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,
|
||||
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
|
||||
value_len INT NOT NULL DEFAULT 0,
|
||||
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)
|
||||
);
|
||||
|
||||
-- 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_entry_id ON secrets(entry_id);
|
||||
|
||||
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)
|
||||
-- ── kv_config: global key-value store (Argon2id salt, etc.) ────────────
|
||||
CREATE TABLE IF NOT EXISTS kv_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BYTEA NOT NULL
|
||||
);
|
||||
|
||||
-- ── 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,
|
||||
@@ -83,14 +71,13 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
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_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 (
|
||||
-- ── entries_history: entry-level snapshot (tags + metadata) ─────────────
|
||||
CREATE TABLE IF NOT EXISTS entries_history (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
secret_id UUID NOT NULL,
|
||||
entry_id UUID NOT NULL,
|
||||
namespace VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
@@ -98,13 +85,34 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
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);
|
||||
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);
|
||||
|
||||
-- ── 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,
|
||||
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
|
||||
value_len INT NOT NULL DEFAULT 0,
|
||||
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);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -113,33 +121,31 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot parameters grouped to avoid too-many-arguments lint.
|
||||
pub struct SnapshotParams<'a> {
|
||||
pub secret_id: uuid::Uuid,
|
||||
// ── Entry-level history snapshot ────────────────────────────────────────────
|
||||
|
||||
pub struct EntrySnapshotParams<'a> {
|
||||
pub entry_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],
|
||||
pub metadata: &'a Value,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
/// Snapshot an entry row into `entries_history` before a write operation.
|
||||
pub async fn snapshot_entry_history(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
p: SnapshotParams<'_>,
|
||||
p: EntrySnapshotParams<'_>,
|
||||
) -> 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)",
|
||||
"INSERT INTO entries_history \
|
||||
(entry_id, namespace, kind, name, version, action, tags, metadata, actor) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
)
|
||||
.bind(p.secret_id)
|
||||
.bind(p.entry_id)
|
||||
.bind(p.namespace)
|
||||
.bind(p.kind)
|
||||
.bind(p.name)
|
||||
@@ -147,15 +153,53 @@ pub async fn snapshot_history(
|
||||
.bind(p.action)
|
||||
.bind(p.tags)
|
||||
.bind(p.metadata)
|
||||
.bind(p.encrypted)
|
||||
.bind(&actor)
|
||||
.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 field_type: &'a str,
|
||||
pub value_len: i32,
|
||||
pub encrypted: &'a [u8],
|
||||
pub action: &'a str,
|
||||
}
|
||||
|
||||
/// Snapshot a single secret field into `secrets_history`.
|
||||
pub async fn snapshot_secret_history(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
p: SecretSnapshotParams<'_>,
|
||||
) -> Result<()> {
|
||||
let actor = std::env::var("USER").unwrap_or_default();
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets_history \
|
||||
(entry_id, secret_id, entry_version, field_name, field_type, value_len, encrypted, action, actor) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
)
|
||||
.bind(p.entry_id)
|
||||
.bind(p.secret_id)
|
||||
.bind(p.entry_version)
|
||||
.bind(p.field_name)
|
||||
.bind(p.field_type)
|
||||
.bind(p.value_len)
|
||||
.bind(p.encrypted)
|
||||
.bind(p.action)
|
||||
.bind(&actor)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Argon2 salt helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the Argon2id salt from the database.
|
||||
/// Returns None if not yet initialized.
|
||||
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {
|
||||
let row: Option<(Vec<u8>,)> =
|
||||
sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'")
|
||||
|
||||
Reference in New Issue
Block a user