refactor: workspace secrets-core + secrets-mcp MCP SaaS
- Split library (db/crypto/service) and MCP/Web/OAuth binary - Add deploy examples and CI/docs updates Made-with: Cursor
This commit is contained in:
228
crates/secrets-core/src/db.rs
Normal file
228
crates/secrets-core/src/db.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
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,
|
||||
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);
|
||||
|
||||
-- ── 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);
|
||||
|
||||
-- ── 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 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) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
)
|
||||
.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)
|
||||
.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 ────────────────────────────────────────────────────────────────
|
||||
Reference in New Issue
Block a user