feat(nn): entry–secret N:N, unique secret names, web unlink
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 2m37s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped

Bump secrets-mcp to 0.3.8 (tag 0.3.7 already used).

- Junction table entry_secrets; secrets user-scoped with type
- Per-user unique secrets.name; link_secret_names on add
- Manual migrations + migrate script; MCP/tool and Web updates

Made-with: Cursor
This commit is contained in:
王松
2026-04-03 17:37:04 +08:00
parent df701f21b9
commit c6fb457734
20 changed files with 1103 additions and 198 deletions

View File

@@ -83,16 +83,30 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
-- ── 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,
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(),
UNIQUE(entry_id, field_name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
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 (
@@ -141,17 +155,13 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
-- ── 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,
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_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);
@@ -210,6 +220,16 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
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'
@@ -499,10 +519,8 @@ pub async fn snapshot_entry_history(
// ── 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 name: &'a str,
pub encrypted: &'a [u8],
pub action: &'a str,
}
@@ -513,13 +531,11 @@ pub async fn snapshot_secret_history(
) -> Result<()> {
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6)",
(secret_id, name, encrypted, action) \
VALUES ($1, $2, $3, $4)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.name)
.bind(p.encrypted)
.bind(p.action)
.execute(&mut **tx)

View File

@@ -27,8 +27,11 @@ pub struct Entry {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
pub user_id: Option<Uuid>,
pub name: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub secret_type: String,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
@@ -83,7 +86,7 @@ impl From<&EntryWriteRow> for EntryRow {
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub name: String,
pub encrypted: Vec<u8>,
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::collections::{BTreeSet, HashSet};
use std::fs;
use uuid::Uuid;
@@ -176,6 +177,7 @@ pub struct AddParams<'a> {
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
pub link_secret_names: &'a [String],
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
pub user_id: Option<Uuid>,
}
@@ -185,6 +187,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
let secret_json = build_json(params.secret_entries)?;
let meta_keys = collect_key_paths(params.meta_entries)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let flat_fields = flatten_json_fields("", &secret_json);
let new_secret_names: BTreeSet<String> =
flat_fields.iter().map(|(name, _)| name.clone()).collect();
let link_secret_names =
validate_link_secret_names(params.link_secret_names, &new_secret_names)?;
let mut tx = pool.begin().await?;
@@ -279,10 +286,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.await?
};
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
let current_entry_version: i64 =
sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
@@ -293,7 +301,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
folder: params.folder,
entry_type: params.entry_type,
name: params.name,
version: new_entry_version,
version: current_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
@@ -308,23 +316,25 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_name: String,
name: String,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
for f in &existing_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id,
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
name: &f.name,
encrypted: &f.encrypted,
action: "add",
},
@@ -335,21 +345,68 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
}
}
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.execute(&mut *tx)
.await?;
}
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name)
.bind(infer_secret_type(field_name))
.bind(&encrypted)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id)
.bind(secret_id)
.execute(&mut *tx)
.await?;
}
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
for link_name in &link_secret_names {
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(link_name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(link_name)
.fetch_all(&mut *tx)
.await?
};
match secret_ids.len() {
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
1 => {
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(entry_id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
crate::audit::log_tx(
@@ -379,9 +436,56 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
})
}
pub(crate) fn infer_secret_type(name: &str) -> &'static str {
match name {
"ssh_key" => "pem",
"password" => "password",
"phone" | "phone_2" => "phone",
"webhook_url" | "address" => "url",
"access_key_id"
| "access_key_secret"
| "global_api_key"
| "api_key"
| "secret_key"
| "personal_access_token"
| "runner_token"
| "GOOGLE_CLIENT_ID"
| "GOOGLE_CLIENT_SECRET" => "token",
_ => "text",
}
}
fn validate_link_secret_names(
link_secret_names: &[String],
new_secret_names: &BTreeSet<String>,
) -> Result<Vec<String>> {
let mut deduped = Vec::new();
let mut seen = HashSet::new();
for raw in link_secret_names {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
if new_secret_names.contains(trimmed) {
anyhow::bail!(
"Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names",
trimmed
);
}
if seen.insert(trimmed.to_string()) {
deduped.push(trimmed.to_string());
}
}
Ok(deduped)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
use std::collections::BTreeSet;
#[test]
fn parse_nested_file_shorthand() {
@@ -410,4 +514,199 @@ mod tests {
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
#[test]
fn validate_link_secret_names_conflict_with_new_secret() {
let mut new_names = BTreeSet::new();
new_names.insert("password".to_string());
let err = validate_link_secret_names(&[String::from("password")], &new_names)
.expect_err("must fail on overlap");
assert!(
err.to_string()
.contains("provided both in secrets/secrets_obj and link_secret_names")
);
}
#[test]
fn validate_link_secret_names_dedup_and_trim() {
let names = vec![
" shared_key ".to_string(),
"shared_key".to_string(),
"runner_token".to_string(),
];
let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap();
assert_eq!(deduped, vec!["shared_key", "runner_token"]);
}
async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set");
return None;
};
let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip add linkage tests: cannot connect to database");
return None;
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip add linkage tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> {
sqlx::query(
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
sqlx::query(
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn add_links_existing_secret_by_unique_name() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_unique_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id",
)
.bind(&secret_name)
.bind(vec![1_u8, 2, 3])
.fetch_one(&pool)
.await?;
run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await?;
let linked: bool = sqlx::query_scalar(
"SELECT EXISTS( \
SELECT 1 FROM entry_secrets es \
JOIN entries e ON e.id = es.entry_id \
WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \
)",
)
.bind(&entry_name)
.bind(secret_id)
.fetch_one(&pool)
.await?;
assert!(linked);
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_not_found_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_missing_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail when linked secret is not found");
assert!(err.to_string().contains("Not found: secret named"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_ambiguous_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_amb_{}", &suffix[..8]);
let secret_name = format!("{}_dup_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![1_u8])
.execute(&pool)
.await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![2_u8])
.execute(&pool)
.await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail on ambiguous linked secret name");
assert!(err.to_string().contains("Ambiguous:"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
}

View File

@@ -130,20 +130,20 @@ async fn migrate_key_refs_if_needed(
let owner = &refs[0];
let owner_path = ref_path(owner);
let key_fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(key_row.id)
.fetch_all(&mut **tx)
.await?;
let key_fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(key_row.id)
.fetch_all(&mut **tx)
.await?;
for f in &key_fields {
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO NOTHING",
)
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(owner.id)
.bind(&f.field_name)
.bind(&f.encrypted)
.bind(f.id)
.execute(&mut **tx)
.await?;
}
@@ -200,7 +200,7 @@ async fn migrate_key_refs_if_needed(
Ok(refs.iter().map(ref_label).collect())
}
/// Delete a single entry by id (multi-tenant: `user_id` must match). Cascades `secrets` via FK.
/// Delete a single entry by id (multi-tenant: `user_id` must match).
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
@@ -615,20 +615,22 @@ async fn snapshot_and_delete(
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
name: &f.name,
encrypted: &f.encrypted,
action: "delete",
},
@@ -644,6 +646,13 @@ async fn snapshot_and_delete(
.execute(&mut **tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.execute(&mut **tx)
.await?;
Ok(())
}
@@ -692,6 +701,31 @@ mod tests {
Ok(())
}
async fn insert_secret_for_entry(
pool: &PgPool,
user_id: Uuid,
entry_id: Uuid,
name: &str,
secret_type: &str,
encrypted: Vec<u8>,
) -> Result<()> {
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(user_id)
.bind(name)
.bind(secret_type)
.bind(encrypted)
.fetch_one(pool)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id)
.bind(secret_id)
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn delete_shared_key_dry_run_reports_migration_without_writes() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
@@ -713,12 +747,7 @@ mod tests {
json!({}),
)
.await?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(key_id)
.bind("pem")
.bind(vec![1_u8, 2, 3])
.execute(&pool)
.await?;
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![1_u8, 2, 3]).await?;
insert_entry(
&pool,
@@ -808,12 +837,7 @@ mod tests {
json!({}),
)
.await?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(key_id)
.bind("pem")
.bind(vec![7_u8, 8, 9])
.execute(&pool)
.await?;
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![7_u8, 8, 9]).await?;
// owner candidate (sorted first by folder)
insert_entry(
@@ -893,7 +917,12 @@ mod tests {
assert_eq!(ref_c_key_ref.as_deref(), Some("afolder/srv-a"));
let owner_has_copied: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM secrets WHERE entry_id = $1 AND field_name = 'pem')",
"SELECT EXISTS( \
SELECT 1 \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = 'pem' \
)",
)
.bind(ref_a)
.fetch_one(&pool)

View File

@@ -51,7 +51,7 @@ async fn build_entry_env_map(
} else {
all_fields
.iter()
.filter(|f| only_fields.contains(&f.field_name))
.filter(|f| only_fields.contains(&f.name))
.collect()
};
@@ -63,7 +63,7 @@ async fn build_entry_env_map(
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
f.name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_to_env_string(&decrypted));
}
@@ -97,7 +97,7 @@ async fn build_entry_env_map(
let key_var = format!(
"{}_{}",
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
f.name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key_var, json_to_env_string(&decrypted));
}

View File

@@ -55,7 +55,7 @@ pub async fn export(
let mut map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
map.insert(f.name.clone(), decrypted);
}
Some(map)
}

View File

@@ -25,7 +25,7 @@ pub async fn get_secret_field(
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.find(|f| f.name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
@@ -49,7 +49,7 @@ pub async fn get_all_secrets(
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
map.insert(f.name.clone(), decrypted);
}
Ok(map)
}
@@ -72,7 +72,7 @@ pub async fn get_secret_field_by_id(
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.find(|f| f.name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
@@ -98,7 +98,7 @@ pub async fn get_all_secrets_by_id(
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
map.insert(f.name.clone(), decrypted);
}
Ok(map)
}

View File

@@ -85,6 +85,7 @@ pub async fn run(
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
link_secret_names: &[],
user_id: params.user_id,
},
master_key,

View File

@@ -3,7 +3,6 @@ use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
#[derive(Debug, serde::Serialize)]
@@ -27,7 +26,6 @@ pub async fn run(
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
@@ -122,7 +120,7 @@ pub async fn run(
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT entry_id, folder, type, version, action, tags, metadata \
"SELECT folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
)
@@ -132,7 +130,7 @@ pub async fn run(
.await?
} else {
sqlx::query_as(
"SELECT entry_id, folder, type, version, action, tags, metadata \
"SELECT folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
@@ -151,33 +149,7 @@ pub async fn run(
)
})?;
#[derive(sqlx::FromRow)]
struct SecretHistoryRow {
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT field_name, encrypted, action FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
)
.bind(snap.entry_id)
.bind(snap.version)
.fetch_all(pool)
.await?;
for f in &field_snaps {
if f.action != "delete" && !f.encrypted.is_empty() {
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
anyhow::anyhow!(
"Cannot decrypt snapshot for field '{}': {}",
f.field_name,
e
)
})?;
}
}
let _ = master_key;
let mut tx = pool.begin().await?;
@@ -226,23 +198,25 @@ pub async fn run(
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
field_name: String,
name: String,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
for f in &live_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: lr.id,
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
name: &f.name,
encrypted: &f.encrypted,
action: "rollback",
},
@@ -297,22 +271,9 @@ pub async fn run(
}
};
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(live_entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
continue;
}
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(live_entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
// In N:N mode, rollback restores entry metadata/tags only.
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here.
let _ = live_entry_id;
crate::audit::log_tx(
&mut tx,

View File

@@ -210,8 +210,12 @@ pub async fn fetch_secret_schemas(
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
let fields: Vec<EntrySecretRow> = sqlx::query_as(
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = ANY($1) \
ORDER BY es.entry_id, es.sort_order, s.name",
)
.bind(entry_ids)
.fetch_all(pool)
@@ -219,7 +223,8 @@ pub async fn fetch_secret_schemas(
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
let entry_id = f.entry_id;
map.entry(entry_id).or_default().push(f.secret());
}
Ok(map)
}
@@ -232,8 +237,12 @@ pub async fn fetch_secrets_for_entries(
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
let fields: Vec<EntrySecretRow> = sqlx::query_as(
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = ANY($1) \
ORDER BY es.entry_id, es.sort_order, s.name",
)
.bind(entry_ids)
.fetch_all(pool)
@@ -241,7 +250,8 @@ pub async fn fetch_secrets_for_entries(
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
let entry_id = f.entry_id;
map.entry(entry_id).or_default().push(f.secret());
}
Ok(map)
}
@@ -345,3 +355,32 @@ impl From<EntryRaw> for Entry {
}
}
}
#[derive(sqlx::FromRow)]
struct EntrySecretRow {
entry_id: Uuid,
id: Uuid,
user_id: Option<Uuid>,
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl EntrySecretRow {
fn secret(self) -> SecretField {
SecretField {
id: self.id,
user_id: self.user_id,
name: self.name,
secret_type: self.secret_type,
encrypted: self.encrypted,
version: self.version,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}

View File

@@ -7,8 +7,8 @@ use crate::crypto;
use crate::db;
use crate::models::{EntryRow, EntryWriteRow};
use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
collect_field_paths, collect_key_paths, flatten_json_fields, infer_secret_type, insert_path,
parse_key_path, parse_kv, remove_path,
};
#[derive(Debug, serde::Serialize)]
@@ -173,8 +173,6 @@ pub async fn run(
);
}
let new_version = row.version + 1;
for entry in params.secret_entries {
let (path, field_value) = parse_kv(entry)?;
let flat = flatten_json_fields("", &{
@@ -192,7 +190,10 @@ pub async fn run(
encrypted: Vec<u8>,
}
let ef: Option<ExistingField> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(field_name)
@@ -203,10 +204,8 @@ pub async fn run(
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
name: field_name,
encrypted: &ef.encrypted,
action: "update",
},
@@ -216,16 +215,30 @@ pub async fn run(
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
if let Some(ef) = ef {
sqlx::query(
"UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2",
)
.bind(&encrypted)
.bind(ef.id)
.execute(&mut *tx)
.await?;
} else {
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name)
.bind(infer_secret_type(field_name))
.bind(&encrypted)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(row.id)
.bind(secret_id)
.execute(&mut *tx)
.await?;
}
}
}
@@ -239,7 +252,10 @@ pub async fn run(
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(&field_name)
@@ -250,10 +266,8 @@ pub async fn run(
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
name: &field_name,
encrypted: &f.encrypted,
action: "delete",
},
@@ -262,10 +276,19 @@ pub async fn run(
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.id)
.bind(f.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}