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:
383
crates/secrets-core/src/service/add.rs
Normal file
383
crates/secrets-core/src/service/add.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::models::EntryRow;
|
||||
|
||||
// ── Key/value parsing helpers ─────────────────────────────────────────────────
|
||||
|
||||
pub fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
|
||||
if let Some((key, json_str)) = entry.split_once(":=") {
|
||||
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
|
||||
key,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
return Ok((parse_key_path(key)?, val));
|
||||
}
|
||||
|
||||
if let Some((key, raw_val)) = entry.split_once('=') {
|
||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
if let Some((key, path)) = entry.split_once('@') {
|
||||
let value = fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_json(entries: &[String]) -> Result<Value> {
|
||||
let mut map = Map::new();
|
||||
for entry in entries {
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut map, &path, value)?;
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
pub fn key_path_to_string(path: &[String]) -> String {
|
||||
path.join(":")
|
||||
}
|
||||
|
||||
pub fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_key_path(key: &str) -> Result<Vec<String>> {
|
||||
let path: Vec<String> = key
|
||||
.split(':')
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
|
||||
anyhow::bail!(
|
||||
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
|
||||
key
|
||||
);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn insert_path(map: &mut Map<String, Value>, path: &[String], value: Value) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
if path.len() == 1 {
|
||||
map.insert(path[0].clone(), value);
|
||||
return Ok(());
|
||||
}
|
||||
let head = path[0].clone();
|
||||
let tail = &path[1..];
|
||||
match map.entry(head.clone()) {
|
||||
serde_json::map::Entry::Vacant(entry) => {
|
||||
let mut child = Map::new();
|
||||
insert_path(&mut child, tail, value)?;
|
||||
entry.insert(Value::Object(child));
|
||||
}
|
||||
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
|
||||
Value::Object(child) => insert_path(child, tail, value)?,
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Cannot set nested key '{}' because '{}' is already a non-object value",
|
||||
key_path_to_string(path),
|
||||
head
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
if path.len() == 1 {
|
||||
return Ok(map.remove(&path[0]).is_some());
|
||||
}
|
||||
let Some(value) = map.get_mut(&path[0]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Value::Object(child) = value else {
|
||||
return Ok(false);
|
||||
};
|
||||
let removed = remove_path(child, &path[1..])?;
|
||||
if child.is_empty() {
|
||||
map.remove(&path[0]);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in map {
|
||||
let full_key = if prefix.is_empty() {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, k)
|
||||
};
|
||||
out.extend(flatten_json_fields(&full_key, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
other => vec![(prefix.to_string(), other.clone())],
|
||||
}
|
||||
}
|
||||
|
||||
// ── AddResult ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct AddResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
pub meta_keys: Vec<String>,
|
||||
pub secret_keys: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct AddParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub name: &'a str,
|
||||
pub tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
||||
let metadata = build_json(params.meta_entries)?;
|
||||
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 mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch existing entry (user-scoped or global depending on user_id)
|
||||
let existing: Option<EntryRow> = if let Some(uid) = params.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",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.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",
|
||||
)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
if let Some(ref ex) = existing
|
||||
&& let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: ex.id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
name: params.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
tags: &ex.tags,
|
||||
metadata: &ex.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
|
||||
}
|
||||
|
||||
let entry_id: Uuid = if let Some(uid) = params.user_id {
|
||||
sqlx::query_scalar(
|
||||
r#"INSERT INTO entries (user_id, namespace, kind, name, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
|
||||
ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
tags = EXCLUDED.tags,
|
||||
metadata = EXCLUDED.metadata,
|
||||
version = entries.version + 1,
|
||||
updated_at = NOW()
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.name)
|
||||
.bind(params.tags)
|
||||
.bind(&metadata)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 1, 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()
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.name)
|
||||
.bind(params.tags)
|
||||
.bind(&metadata)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingField {
|
||||
id: Uuid,
|
||||
field_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?;
|
||||
|
||||
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,
|
||||
encrypted: &f.encrypted,
|
||||
action: "add",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field history");
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
|
||||
.bind(entry_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?;
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"add",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"tags": params.tags,
|
||||
"meta_keys": meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(AddResult {
|
||||
namespace: params.namespace.to_string(),
|
||||
kind: params.kind.to_string(),
|
||||
name: params.name.to_string(),
|
||||
tags: params.tags.to_vec(),
|
||||
meta_keys,
|
||||
secret_keys,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_nested_file_shorthand() {
|
||||
use std::io::Write;
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
writeln!(f, "line1\nline2").unwrap();
|
||||
let path = f.path().to_str().unwrap().to_string();
|
||||
let entry = format!("credentials:content@{}", path);
|
||||
let (path_parts, value) = parse_kv(&entry).unwrap();
|
||||
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
|
||||
assert!(matches!(value, Value::String(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_json_fields_nested() {
|
||||
let v = serde_json::json!({
|
||||
"username": "root",
|
||||
"credentials": {
|
||||
"type": "ssh",
|
||||
"content": "pem"
|
||||
}
|
||||
});
|
||||
let mut fields = flatten_json_fields("", &v);
|
||||
fields.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(fields[0].0, "credentials.content");
|
||||
assert_eq!(fields[1].0, "credentials.type");
|
||||
assert_eq!(fields[2].0, "username");
|
||||
}
|
||||
}
|
||||
55
crates/secrets-core/src/service/api_key.rs
Normal file
55
crates/secrets-core/src/service/api_key.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
const KEY_PREFIX: &str = "sk_";
|
||||
|
||||
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
|
||||
pub fn generate_api_key() -> String {
|
||||
use rand::RngExt;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
format!("{}{}", KEY_PREFIX, hex)
|
||||
}
|
||||
|
||||
/// Return the user's existing API key, or generate and store a new one if NULL.
|
||||
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
||||
let existing: Option<(Option<String>,)> =
|
||||
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((Some(key),)) = existing {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let new_key = generate_api_key();
|
||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
||||
.bind(&new_key)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(new_key)
|
||||
}
|
||||
|
||||
/// Generate a fresh API key for the user, replacing the old one.
|
||||
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
||||
let new_key = generate_api_key();
|
||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
||||
.bind(&new_key)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(new_key)
|
||||
}
|
||||
|
||||
/// Validate a Bearer token. Returns the `user_id` if the key matches.
|
||||
pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uuid>> {
|
||||
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE api_key = $1")
|
||||
.bind(raw_key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id,)| id))
|
||||
}
|
||||
268
crates/secrets-core/src/service/delete.rs
Normal file
268
crates/secrets-core/src/service/delete.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::models::{EntryRow, SecretFieldRow};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeletedEntry {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeleteResult {
|
||||
pub deleted: Vec<DeletedEntry>,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
pub struct DeleteParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub dry_run: bool,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
|
||||
match params.name {
|
||||
Some(name) => {
|
||||
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
|
||||
}
|
||||
None => {
|
||||
delete_bulk(
|
||||
pool,
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.dry_run,
|
||||
params.user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<DeleteResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryRow> = 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 Some(row) = row else {
|
||||
tx.rollback().await?;
|
||||
return Ok(DeleteResult {
|
||||
deleted: vec![],
|
||||
dry_run: false,
|
||||
});
|
||||
};
|
||||
|
||||
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
|
||||
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(DeleteResult {
|
||||
deleted: vec![DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
}],
|
||||
dry_run: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_bulk(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: Option<&str>,
|
||||
dry_run: bool,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<DeleteResult> {
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct FullEntryRow {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
kind: String,
|
||||
name: String,
|
||||
metadata: serde_json::Value,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
let rows: Vec<FullEntryRow> = match (user_id, kind) {
|
||||
(Some(uid), Some(k)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 ORDER BY name",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(k)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(Some(uid), None) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 ORDER BY kind, name",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(None, Some(k)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 ORDER BY name",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(k)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(None, None) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 ORDER BY kind, name",
|
||||
)
|
||||
.bind(namespace)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
if dry_run {
|
||||
let deleted = rows
|
||||
.iter()
|
||||
.map(|r| DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: r.kind.clone(),
|
||||
name: r.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(DeleteResult {
|
||||
deleted,
|
||||
dry_run: true,
|
||||
});
|
||||
}
|
||||
|
||||
let mut deleted = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let entry_row = EntryRow {
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
tags: row.tags.clone(),
|
||||
metadata: row.metadata.clone(),
|
||||
};
|
||||
let mut tx = pool.begin().await?;
|
||||
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"delete",
|
||||
namespace,
|
||||
&row.kind,
|
||||
&row.name,
|
||||
json!({"bulk": true}),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
deleted.push(DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: row.kind.clone(),
|
||||
name: row.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DeleteResult {
|
||||
deleted,
|
||||
dry_run: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn snapshot_and_delete(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
row: &EntryRow,
|
||||
) -> Result<()> {
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
version: row.version,
|
||||
action: "delete",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
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?;
|
||||
|
||||
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,
|
||||
encrypted: &f.encrypted,
|
||||
action: "delete",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
124
crates/secrets-core/src/service/env_map.rs
Normal file
124
crates/secrets-core/src/service/env_map.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Entry;
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
/// Build an env variable map from entry secrets (for dry-run preview or injection).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_env_map(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
only_fields: &[String],
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entries = fetch_entries(pool, namespace, kind, name, tags, None, user_id).await?;
|
||||
|
||||
let mut combined: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for entry in &entries {
|
||||
let entry_map = build_entry_env_map(pool, entry, only_fields, prefix, master_key).await?;
|
||||
combined.extend(entry_map);
|
||||
}
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn build_entry_env_map(
|
||||
pool: &PgPool,
|
||||
entry: &Entry,
|
||||
only_fields: &[String],
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
|
||||
let fields: Vec<_> = if only_fields.is_empty() {
|
||||
all_fields.iter().collect()
|
||||
} else {
|
||||
all_fields
|
||||
.iter()
|
||||
.filter(|f| only_fields.contains(&f.field_name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_to_env_string(&decrypted));
|
||||
}
|
||||
|
||||
// Resolve key_ref
|
||||
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
|
||||
let key_entries = fetch_entries(
|
||||
pool,
|
||||
Some(&entry.namespace),
|
||||
Some("key"),
|
||||
Some(key_ref),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(key_entry) = key_entries.first() {
|
||||
let key_ids = vec![key_entry.id];
|
||||
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
|
||||
let empty = vec![];
|
||||
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
|
||||
let key_prefix = env_prefix(key_entry, prefix);
|
||||
for f in key_fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key_var = format!(
|
||||
"{}_{}",
|
||||
key_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key_var, json_to_env_string(&decrypted));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(key_ref, "key_ref target not found");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
format!(
|
||||
"{}_{}",
|
||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||
name_part
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_env_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
139
crates/secrets-core/src/service/export.rs
Normal file
139
crates/secrets-core/src/service/export.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::{ExportData, ExportEntry, ExportFormat};
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
pub struct ExportParams<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub no_secrets: bool,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn export(
|
||||
pool: &PgPool,
|
||||
params: ExportParams<'_>,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Result<ExportData> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name,
|
||||
params.tags,
|
||||
params.query,
|
||||
params.user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secrets_map: HashMap<Uuid, Vec<_>> = if !params.no_secrets && !entry_ids.is_empty() {
|
||||
fetch_secrets_for_entries(pool, &entry_ids).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
let secrets = if params.no_secrets {
|
||||
None
|
||||
} else {
|
||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if fields.is_empty() {
|
||||
Some(BTreeMap::new())
|
||||
} else {
|
||||
let mk = master_key
|
||||
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
||||
let mut map = BTreeMap::new();
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
||||
map.insert(f.field_name.clone(), decrypted);
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
};
|
||||
|
||||
export_entries.push(ExportEntry {
|
||||
namespace: entry.namespace.clone(),
|
||||
kind: entry.kind.clone(),
|
||||
name: entry.name.clone(),
|
||||
tags: entry.tags.clone(),
|
||||
metadata: entry.metadata.clone(),
|
||||
secrets,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ExportData {
|
||||
version: 1,
|
||||
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
entries: export_entries,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn export_to_file(
|
||||
pool: &PgPool,
|
||||
params: ExportParams<'_>,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
file_path: &str,
|
||||
format_override: Option<&str>,
|
||||
) -> Result<usize> {
|
||||
let format = if let Some(f) = format_override {
|
||||
f.parse::<ExportFormat>()?
|
||||
} else {
|
||||
ExportFormat::from_extension(file_path).unwrap_or(ExportFormat::Json)
|
||||
};
|
||||
|
||||
let data = export(pool, params, master_key).await?;
|
||||
let count = data.entries.len();
|
||||
let serialized = format.serialize(&data)?;
|
||||
std::fs::write(file_path, &serialized)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn export_to_string(
|
||||
pool: &PgPool,
|
||||
params: ExportParams<'_>,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
format: &str,
|
||||
) -> Result<String> {
|
||||
let fmt = format.parse::<ExportFormat>()?;
|
||||
let data = export(pool, params, master_key).await?;
|
||||
fmt.serialize(&data)
|
||||
}
|
||||
|
||||
// ── Build helpers for re-encoding values as CLI-style entries ─────────────────
|
||||
|
||||
pub fn build_meta_entries(metadata: &Value) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(obj) = metadata.as_object() {
|
||||
for (k, v) in obj {
|
||||
entries.push(value_to_kv_entry(k, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(map) = secrets {
|
||||
for (k, v) in map {
|
||||
entries.push(value_to_kv_entry(k, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn value_to_kv_entry(key: &str, value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => format!("{}={}", key, s),
|
||||
other => format!("{}:={}", key, other),
|
||||
}
|
||||
}
|
||||
79
crates/secrets-core/src/service/get_secret.rs
Normal file
79
crates/secrets-core/src/service/get_secret.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
/// Decrypt a single named field from an entry.
|
||||
pub async fn get_secret_field(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
field_name: &str,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Value> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
Some(namespace),
|
||||
Some(kind),
|
||||
Some(name),
|
||||
&[],
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
let entry = entries
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
|
||||
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
|
||||
let field = fields
|
||||
.iter()
|
||||
.find(|f| f.field_name == field_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
|
||||
|
||||
crypto::decrypt_json(master_key, &field.encrypted)
|
||||
}
|
||||
|
||||
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
|
||||
pub async fn get_all_secrets(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
Some(namespace),
|
||||
Some(kind),
|
||||
Some(name),
|
||||
&[],
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
let entry = entries
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
|
||||
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
|
||||
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);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
63
crates/secrets-core/src/service/history.rs
Normal file
63
crates/secrets-core/src/service/history.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub version: i64,
|
||||
pub action: String,
|
||||
pub actor: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
limit: u32,
|
||||
_user_id: Option<Uuid>,
|
||||
) -> Result<Vec<HistoryEntry>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
version: i64,
|
||||
action: String,
|
||||
actor: String,
|
||||
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?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| HistoryEntry {
|
||||
version: r.version,
|
||||
action: r.action,
|
||||
actor: r.actor,
|
||||
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn run_json(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
limit: u32,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Value> {
|
||||
let entries = run(pool, namespace, kind, name, limit, user_id).await?;
|
||||
Ok(serde_json::to_value(entries)?)
|
||||
}
|
||||
123
crates/secrets-core/src/service/import.rs
Normal file
123
crates/secrets-core/src/service/import.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::ExportFormat;
|
||||
use crate::service::add::{AddParams, run as add_run};
|
||||
use crate::service::export::{build_meta_entries, build_secret_entries};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ImportSummary {
|
||||
pub total: usize,
|
||||
pub inserted: usize,
|
||||
pub skipped: usize,
|
||||
pub failed: usize,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
pub struct ImportParams<'a> {
|
||||
pub file: &'a str,
|
||||
pub force: bool,
|
||||
pub dry_run: bool,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
params: ImportParams<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<ImportSummary> {
|
||||
let format = ExportFormat::from_extension(params.file)?;
|
||||
let content = std::fs::read_to_string(params.file)
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", params.file, e))?;
|
||||
let data = format.deserialize(&content)?;
|
||||
|
||||
if data.version != 1 {
|
||||
anyhow::bail!(
|
||||
"Unsupported export version {}. Only version 1 is supported.",
|
||||
data.version
|
||||
);
|
||||
}
|
||||
|
||||
let total = data.entries.len();
|
||||
let mut inserted = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for entry in &data.entries {
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NOT DISTINCT FROM $4)",
|
||||
)
|
||||
.bind(&entry.namespace)
|
||||
.bind(&entry.kind)
|
||||
.bind(&entry.name)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists && !params.force {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Import aborted: conflict on [{}/{}/{}]",
|
||||
entry.namespace,
|
||||
entry.kind,
|
||||
entry.name
|
||||
));
|
||||
}
|
||||
|
||||
if params.dry_run {
|
||||
if exists {
|
||||
skipped += 1;
|
||||
} else {
|
||||
inserted += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
||||
let meta_entries = build_meta_entries(&entry.metadata);
|
||||
|
||||
match add_run(
|
||||
pool,
|
||||
AddParams {
|
||||
namespace: &entry.namespace,
|
||||
kind: &entry.kind,
|
||||
name: &entry.name,
|
||||
tags: &entry.tags,
|
||||
meta_entries: &meta_entries,
|
||||
secret_entries: &secret_entries,
|
||||
user_id: params.user_id,
|
||||
},
|
||||
master_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
inserted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
namespace = entry.namespace,
|
||||
kind = entry.kind,
|
||||
name = entry.name,
|
||||
error = %e,
|
||||
"failed to import entry"
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow::anyhow!("{} record(s) failed to import", failed));
|
||||
}
|
||||
|
||||
Ok(ImportSummary {
|
||||
total,
|
||||
inserted,
|
||||
skipped,
|
||||
failed,
|
||||
dry_run: params.dry_run,
|
||||
})
|
||||
}
|
||||
12
crates/secrets-core/src/service/mod.rs
Normal file
12
crates/secrets-core/src/service/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod add;
|
||||
pub mod api_key;
|
||||
pub mod delete;
|
||||
pub mod env_map;
|
||||
pub mod export;
|
||||
pub mod get_secret;
|
||||
pub mod history;
|
||||
pub mod import;
|
||||
pub mod rollback;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
pub mod user;
|
||||
229
crates/secrets-core/src/service/rollback.rs
Normal file
229
crates/secrets-core/src/service/rollback.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct RollbackResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub restored_version: i64,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
to_version: Option<i64>,
|
||||
master_key: &[u8; 32],
|
||||
_user_id: Option<Uuid>,
|
||||
) -> Result<RollbackResult> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryHistoryRow {
|
||||
entry_id: Uuid,
|
||||
version: i64,
|
||||
action: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
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",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.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",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let snap = snap.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No history found for [{}/{}] {}{}.",
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
to_version
|
||||
.map(|v| format!(" at version {}", v))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
|
||||
#[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 \
|
||||
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 mut tx = pool.begin().await?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveEntry {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
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 {
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: lr.id,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
version: lr.version,
|
||||
action: "rollback",
|
||||
tags: &lr.tags,
|
||||
metadata: &lr.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveField {
|
||||
id: Uuid,
|
||||
field_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?;
|
||||
|
||||
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,
|
||||
encrypted: &f.encrypted,
|
||||
action: "rollback",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
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?;
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
|
||||
.bind(snap.entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for f in &field_snaps {
|
||||
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?;
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"rollback",
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
serde_json::json!({
|
||||
"restored_version": snap.version,
|
||||
"original_action": snap.action,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(RollbackResult {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
restored_version: snap.version,
|
||||
})
|
||||
}
|
||||
241
crates/secrets-core/src/service/search.rs
Normal file
241
crates/secrets-core/src/service/search.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Entry, SecretField};
|
||||
|
||||
pub const FETCH_ALL_LIMIT: u32 = 100_000;
|
||||
|
||||
pub struct SearchParams<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub sort: &'a str,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
/// Multi-user: filter by this user_id. None = single-user / no filter.
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub entries: Vec<Entry>,
|
||||
pub secret_schemas: HashMap<Uuid, Vec<SecretField>>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
|
||||
let entries = fetch_entries_paged(pool, ¶ms).await?;
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secret_schemas = if !entry_ids.is_empty() {
|
||||
fetch_secret_schemas(pool, &entry_ids).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
Ok(SearchResult {
|
||||
entries,
|
||||
secret_schemas,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
|
||||
pub async fn fetch_entries(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<Entry>> {
|
||||
let params = SearchParams {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
tags,
|
||||
query,
|
||||
sort: "name",
|
||||
limit: FETCH_ALL_LIMIT,
|
||||
offset: 0,
|
||||
user_id,
|
||||
};
|
||||
fetch_entries_paged(pool, ¶ms).await
|
||||
}
|
||||
|
||||
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut idx: i32 = 1;
|
||||
|
||||
// user_id filtering — always comes first when present
|
||||
if a.user_id.is_some() {
|
||||
conditions.push(format!("user_id = ${}", idx));
|
||||
idx += 1;
|
||||
} else {
|
||||
conditions.push("user_id IS NULL".to_string());
|
||||
}
|
||||
|
||||
if a.namespace.is_some() {
|
||||
conditions.push(format!("namespace = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if a.kind.is_some() {
|
||||
conditions.push(format!("kind = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if a.name.is_some() {
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if !a.tags.is_empty() {
|
||||
let placeholders: Vec<String> = a
|
||||
.tags
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let p = format!("${}", idx);
|
||||
idx += 1;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
conditions.push(format!(
|
||||
"tags @> ARRAY[{}]::text[]",
|
||||
placeholders.join(", ")
|
||||
));
|
||||
}
|
||||
if a.query.is_some() {
|
||||
conditions.push(format!(
|
||||
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' \
|
||||
OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' \
|
||||
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
|
||||
i = idx
|
||||
));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let order = match a.sort {
|
||||
"updated" => "updated_at DESC",
|
||||
"created" => "created_at DESC",
|
||||
_ => "name ASC",
|
||||
};
|
||||
|
||||
let limit_idx = idx;
|
||||
idx += 1;
|
||||
let offset_idx = idx;
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, \
|
||||
namespace, kind, name, tags, metadata, version, created_at, updated_at \
|
||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||
);
|
||||
|
||||
let mut q = sqlx::query_as::<_, EntryRaw>(&sql);
|
||||
|
||||
if let Some(uid) = a.user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(v) = a.namespace {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.kind {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.name {
|
||||
q = q.bind(v);
|
||||
}
|
||||
for tag in a.tags {
|
||||
q = q.bind(tag);
|
||||
}
|
||||
if let Some(v) = a.query {
|
||||
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
||||
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
Ok(rows.into_iter().map(Entry::from).collect())
|
||||
}
|
||||
|
||||
/// Fetch secret field names for a set of entry ids (no decryption).
|
||||
pub async fn fetch_secret_schemas(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
||||
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",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
|
||||
pub async fn fetch_secrets_for_entries(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
||||
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",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryRaw {
|
||||
id: Uuid,
|
||||
#[allow(dead_code)] // Selected for row shape; Entry model has no user_id field
|
||||
user_id: Uuid,
|
||||
namespace: String,
|
||||
kind: String,
|
||||
name: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<EntryRaw> for Entry {
|
||||
fn from(r: EntryRaw) -> Self {
|
||||
Entry {
|
||||
id: r.id,
|
||||
namespace: r.namespace,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
tags: r.tags,
|
||||
metadata: r.metadata,
|
||||
version: r.version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
271
crates/secrets-core/src/service/update.rs
Normal file
271
crates/secrets-core/src/service/update.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::models::EntryRow;
|
||||
use crate::service::add::{
|
||||
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
||||
parse_kv, remove_path,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UpdateResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub add_tags: Vec<String>,
|
||||
pub remove_tags: Vec<String>,
|
||||
pub meta_keys: Vec<String>,
|
||||
pub remove_meta: Vec<String>,
|
||||
pub secret_keys: Vec<String>,
|
||||
pub remove_secrets: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct UpdateParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub name: &'a str,
|
||||
pub add_tags: &'a [String],
|
||||
pub remove_tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
pub remove_meta: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
pub remove_secrets: &'a [String],
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
params: UpdateParams<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<UpdateResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryRow> = if let Some(uid) = params.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(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.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(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let row = row.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Not found: [{}/{}] {}. Use `add` to create it first.",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
name: params.name,
|
||||
version: row.version,
|
||||
action: "update",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before update");
|
||||
}
|
||||
|
||||
let mut tags: Vec<String> = row.tags.clone();
|
||||
for t in params.add_tags {
|
||||
if !tags.contains(t) {
|
||||
tags.push(t.clone());
|
||||
}
|
||||
}
|
||||
tags.retain(|t| !params.remove_tags.contains(t));
|
||||
|
||||
let mut meta_map: Map<String, Value> = match row.metadata.clone() {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in params.meta_entries {
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut meta_map, &path, value)?;
|
||||
}
|
||||
for key in params.remove_meta {
|
||||
let path = parse_key_path(key)?;
|
||||
remove_path(&mut meta_map, &path)?;
|
||||
}
|
||||
let metadata = Value::Object(meta_map);
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $3 AND version = $4",
|
||||
)
|
||||
.bind(&tags)
|
||||
.bind(&metadata)
|
||||
.bind(row.id)
|
||||
.bind(row.version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Concurrent modification detected for [{}/{}] {}. Please retry.",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name
|
||||
);
|
||||
}
|
||||
|
||||
let new_version = row.version + 1;
|
||||
|
||||
for entry in params.secret_entries {
|
||||
let (path, field_value) = parse_kv(entry)?;
|
||||
let flat = flatten_json_fields("", &{
|
||||
let mut m = Map::new();
|
||||
insert_path(&mut m, &path, field_value)?;
|
||||
Value::Object(m)
|
||||
});
|
||||
|
||||
for (field_name, fv) in &flat {
|
||||
let encrypted = crypto::encrypt_json(master_key, fv)?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingField {
|
||||
id: Uuid,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let ef: Option<ExistingField> = sqlx::query_as(
|
||||
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(field_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ef) = &ef
|
||||
&& 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,
|
||||
encrypted: &ef.encrypted,
|
||||
action: "update",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
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?;
|
||||
}
|
||||
}
|
||||
|
||||
for key in params.remove_secrets {
|
||||
let path = parse_key_path(key)?;
|
||||
let field_name = path.join(".");
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FieldToDelete {
|
||||
id: Uuid,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let field: Option<FieldToDelete> = sqlx::query_as(
|
||||
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(&field_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(f) = field {
|
||||
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,
|
||||
encrypted: &f.encrypted,
|
||||
action: "delete",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
|
||||
}
|
||||
sqlx::query("DELETE FROM secrets WHERE id = $1")
|
||||
.bind(f.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
||||
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
|
||||
let secret_keys = collect_key_paths(params.secret_entries)?;
|
||||
let remove_secret_keys = collect_field_paths(params.remove_secrets)?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"update",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"add_tags": params.add_tags,
|
||||
"remove_tags": params.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
namespace: params.namespace.to_string(),
|
||||
kind: params.kind.to_string(),
|
||||
name: params.name.to_string(),
|
||||
add_tags: params.add_tags.to_vec(),
|
||||
remove_tags: params.remove_tags.to_vec(),
|
||||
meta_keys,
|
||||
remove_meta: remove_meta_keys,
|
||||
secret_keys,
|
||||
remove_secrets: remove_secret_keys,
|
||||
})
|
||||
}
|
||||
213
crates/secrets-core/src/service/user.rs
Normal file
213
crates/secrets-core/src/service/user.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{OauthAccount, User};
|
||||
|
||||
pub struct OAuthProfile {
|
||||
pub provider: String,
|
||||
pub provider_id: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Find or create a user from an OAuth profile.
|
||||
/// Returns (user, is_new) where is_new indicates first-time registration.
|
||||
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
|
||||
// Check if this OAuth account already exists
|
||||
let existing: Option<OauthAccount> = sqlx::query_as(
|
||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
||||
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(oa) = existing {
|
||||
let user: User = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(oa.user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
return Ok((user, false));
|
||||
}
|
||||
|
||||
// New user — create records (no key yet; user sets passphrase on dashboard)
|
||||
let display_name = profile
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
|
||||
)
|
||||
.bind(&profile.email)
|
||||
.bind(&display_name)
|
||||
.bind(&profile.avatar_url)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.bind(&profile.email)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.avatar_url)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((user, true))
|
||||
}
|
||||
|
||||
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
|
||||
pub async fn update_user_key_setup(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
key_salt: &[u8],
|
||||
key_check: &[u8],
|
||||
key_params: &Value,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
|
||||
WHERE id = $4",
|
||||
)
|
||||
.bind(key_salt)
|
||||
.bind(key_check)
|
||||
.bind(key_params)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a user by ID.
|
||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// List all OAuth accounts linked to a user.
|
||||
pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result<Vec<OauthAccount>> {
|
||||
let accounts = sqlx::query_as(
|
||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
||||
FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
/// Bind an additional OAuth account to an existing user.
|
||||
pub async fn bind_oauth_account(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
profile: OAuthProfile,
|
||||
) -> Result<OauthAccount> {
|
||||
// Check if this provider_id is already linked to someone else
|
||||
let conflict: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((existing_user_id,)) = conflict {
|
||||
if existing_user_id != user_id {
|
||||
anyhow::bail!(
|
||||
"This {} account is already linked to a different user",
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"This {} account is already linked to your account",
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
|
||||
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&profile.provider)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if existing_provider_for_user.is_some() {
|
||||
anyhow::bail!(
|
||||
"You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.",
|
||||
profile.provider,
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
|
||||
let account: OauthAccount = sqlx::query_as(
|
||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.bind(&profile.email)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.avatar_url)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider.
|
||||
pub async fn unbind_oauth_account(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
provider: &str,
|
||||
current_login_provider: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if current_login_provider == Some(provider) {
|
||||
anyhow::bail!(
|
||||
"Cannot unlink the {} account you are currently using to sign in",
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if count <= 1 {
|
||||
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
|
||||
.bind(user_id)
|
||||
.bind(provider)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user