Some checks failed
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- Add secrets upgrade command: --check to verify, default to download and replace binary - No database or master key required - Support tar.gz and zip artifacts from Gitea Release Made-with: Cursor
178 lines
6.7 KiB
Rust
178 lines
6.7 KiB
Rust
use anyhow::Result;
|
|
use sqlx::PgPool;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
|
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
|
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<Option<Vec<u8>>> {
|
|
let row: Option<(Vec<u8>,)> =
|
|
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(())
|
|
}
|