feat(nn): entry–secret N:N, unique secret names, web unlink
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:
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user