feat: user-scoped history/delete/rollback, dashboard & login UI, ignore *.pem
- Filter history/rollback/delete by user_id in secrets-core - MCP tools/web pass user context; dashboard refresh; favicon static - .gitignore *.pem; vscode tasks tweaks - clippy: collapse else-if in rollback latest-history branch Made-with: Cursor
This commit is contained in:
@@ -215,6 +215,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: ex.id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
name: params.name,
|
||||
@@ -275,6 +276,26 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing.is_none()
|
||||
&& let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
name: params.name,
|
||||
version: new_entry_version,
|
||||
action: "create",
|
||||
tags: params.tags,
|
||||
metadata: &metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history on create");
|
||||
}
|
||||
|
||||
if existing.is_some() {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingField {
|
||||
|
||||
@@ -33,7 +33,15 @@ pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult
|
||||
let kind = params
|
||||
.kind
|
||||
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
|
||||
delete_one(pool, params.namespace, kind, name, params.user_id).await
|
||||
delete_one(
|
||||
pool,
|
||||
params.namespace,
|
||||
kind,
|
||||
name,
|
||||
params.dry_run,
|
||||
params.user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
delete_bulk(
|
||||
@@ -53,8 +61,48 @@ async fn delete_one(
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
dry_run: bool,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<DeleteResult> {
|
||||
if dry_run {
|
||||
let exists: bool = if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let deleted = if exists {
|
||||
vec![DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
return Ok(DeleteResult {
|
||||
deleted,
|
||||
dry_run: true,
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryRow> = if let Some(uid) = user_id {
|
||||
@@ -88,7 +136,7 @@ async fn delete_one(
|
||||
});
|
||||
};
|
||||
|
||||
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
|
||||
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
|
||||
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -186,7 +234,10 @@ async fn delete_bulk(
|
||||
metadata: row.metadata.clone(),
|
||||
};
|
||||
let mut tx = pool.begin().await?;
|
||||
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
|
||||
snapshot_and_delete(
|
||||
&mut tx, namespace, &row.kind, &row.name, &entry_row, user_id,
|
||||
)
|
||||
.await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"delete",
|
||||
@@ -216,11 +267,13 @@ async fn snapshot_and_delete(
|
||||
kind: &str,
|
||||
name: &str,
|
||||
row: &EntryRow,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
user_id,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
|
||||
@@ -107,11 +107,9 @@ fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
format!(
|
||||
"{}_{}",
|
||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||
name_part
|
||||
)
|
||||
let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
let normalized = normalized.trim_end_matches('_');
|
||||
format!("{}_{}", normalized, name_part)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pub async fn run(
|
||||
kind: &str,
|
||||
name: &str,
|
||||
limit: u32,
|
||||
_user_id: Option<Uuid>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<HistoryEntry>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
@@ -27,17 +27,32 @@ pub async fn run(
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
"SELECT version, action, actor, created_at FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
ORDER BY id DESC LIMIT $4",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let rows: Vec<Row> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT version, action, actor, created_at FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
|
||||
ORDER BY id DESC LIMIT $5",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT version, action, actor, created_at FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
|
||||
ORDER BY id DESC LIMIT $4",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn run(
|
||||
name: &str,
|
||||
to_version: Option<i64>,
|
||||
master_key: &[u8; 32],
|
||||
_user_id: Option<Uuid>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<RollbackResult> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryHistoryRow {
|
||||
@@ -33,21 +33,49 @@ pub async fn run(
|
||||
}
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
AND user_id = $5 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
AND user_id = $4 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 ORDER BY id DESC LIMIT 1",
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
@@ -70,14 +98,13 @@ pub async fn run(
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SecretHistoryRow {
|
||||
secret_id: Uuid,
|
||||
field_name: String,
|
||||
encrypted: Vec<u8>,
|
||||
action: String,
|
||||
}
|
||||
|
||||
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
|
||||
"SELECT secret_id, field_name, encrypted, action FROM secrets_history \
|
||||
"SELECT field_name, encrypted, action FROM secrets_history \
|
||||
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
|
||||
)
|
||||
.bind(snap.entry_id)
|
||||
@@ -106,21 +133,38 @@ pub async fn run(
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
let live: Option<LiveEntry> = sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata FROM entries \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ref lr) = live {
|
||||
// Query live entry with correct user_id scoping to avoid PK conflicts
|
||||
let live: Option<LiveEntry> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let entry_id = if let Some(ref lr) = live {
|
||||
// Snapshot current state before overwriting
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: lr.id,
|
||||
user_id,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
@@ -164,27 +208,55 @@ pub async fn run(
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entries (id, namespace, kind, name, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) \
|
||||
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL DO UPDATE SET \
|
||||
tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, \
|
||||
version = entries.version + 1, updated_at = NOW()",
|
||||
)
|
||||
.bind(snap.entry_id)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(snap.version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Update the existing row in-place to preserve its primary key and user_id
|
||||
sqlx::query(
|
||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
|
||||
updated_at = NOW() WHERE id = $3",
|
||||
)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(lr.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
lr.id
|
||||
} else {
|
||||
// No live entry — insert a fresh one with a new UUID
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(snap.version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(namespace, kind, name, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(snap.version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
|
||||
.bind(snap.entry_id)
|
||||
.bind(entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -192,17 +264,12 @@ pub async fn run(
|
||||
if f.action == "delete" {
|
||||
continue;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (id, entry_id, field_name, encrypted) VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
|
||||
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
|
||||
)
|
||||
.bind(f.secret_id)
|
||||
.bind(snap.entry_id)
|
||||
.bind(&f.field_name)
|
||||
.bind(&f.encrypted)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
|
||||
.bind(entry_id)
|
||||
.bind(&f.field_name)
|
||||
.bind(&f.encrypted)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
|
||||
@@ -80,6 +80,7 @@ pub async fn run(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
name: params.name,
|
||||
|
||||
Reference in New Issue
Block a user