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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user