- Rename namespace/kind to folder/type on entries, audit_log, and history tables; add notes. Unique key is (user_id, folder, name). - Service layer and MCP tools support name-first lookup with optional folder when multiple entries share the same name. - secrets_delete dry_run uses the same disambiguation as real deletes. - Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and AGENTS.md. Made-with: Cursor
277 lines
9.7 KiB
Rust
277 lines
9.7 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use uuid::Uuid;
|
|
|
|
/// A top-level entry (server, service, key, person, …).
|
|
/// Sensitive fields are stored separately in `secrets`.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct Entry {
|
|
pub id: Uuid,
|
|
pub user_id: Option<Uuid>,
|
|
pub folder: String,
|
|
#[serde(rename = "type")]
|
|
#[sqlx(rename = "type")]
|
|
pub entry_type: String,
|
|
pub name: String,
|
|
pub notes: String,
|
|
pub tags: Vec<String>,
|
|
pub metadata: Value,
|
|
pub version: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// A single encrypted field belonging to an Entry.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct SecretField {
|
|
pub id: Uuid,
|
|
pub entry_id: Uuid,
|
|
pub field_name: String,
|
|
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
|
|
pub encrypted: Vec<u8>,
|
|
pub version: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
// ── Internal query row types (shared across commands) ─────────────────────────
|
|
|
|
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
pub struct EntryRow {
|
|
pub id: Uuid,
|
|
pub version: i64,
|
|
pub folder: String,
|
|
#[sqlx(rename = "type")]
|
|
pub entry_type: String,
|
|
pub tags: Vec<String>,
|
|
pub metadata: Value,
|
|
pub notes: String,
|
|
}
|
|
|
|
/// Minimal secret field row fetched before snapshots or cascade deletes.
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
pub struct SecretFieldRow {
|
|
pub id: Uuid,
|
|
pub field_name: String,
|
|
pub encrypted: Vec<u8>,
|
|
}
|
|
|
|
// ── Export / Import types ──────────────────────────────────────────────────────
|
|
|
|
/// Supported file formats for export/import.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum ExportFormat {
|
|
Json,
|
|
Toml,
|
|
Yaml,
|
|
}
|
|
|
|
impl std::str::FromStr for ExportFormat {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s.to_lowercase().as_str() {
|
|
"json" => Ok(Self::Json),
|
|
"toml" => Ok(Self::Toml),
|
|
"yaml" | "yml" => Ok(Self::Yaml),
|
|
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ExportFormat {
|
|
/// Infer format from file extension (.json / .toml / .yaml / .yml).
|
|
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
|
|
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
ext.parse().map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
|
|
ext
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Serialize ExportData to a string in this format.
|
|
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
|
|
match self {
|
|
Self::Json => Ok(serde_json::to_string_pretty(data)?),
|
|
Self::Toml => {
|
|
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
|
|
toml::to_string_pretty(&toml_val)
|
|
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
|
|
}
|
|
Self::Yaml => serde_yaml::to_string(data)
|
|
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
/// Deserialize ExportData from a string in this format.
|
|
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
|
|
match self {
|
|
Self::Json => Ok(serde_json::from_str(content)?),
|
|
Self::Toml => {
|
|
let toml_val: toml::Value = toml::from_str(content)
|
|
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
|
|
let json_val = toml_to_json_value(&toml_val);
|
|
Ok(serde_json::from_value(json_val)?)
|
|
}
|
|
Self::Yaml => serde_yaml::from_str(content)
|
|
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level structure for export/import files.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ExportData {
|
|
pub version: u32,
|
|
pub exported_at: String,
|
|
pub entries: Vec<ExportEntry>,
|
|
}
|
|
|
|
/// A single entry with decrypted secrets for export/import.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ExportEntry {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub folder: String,
|
|
#[serde(default, rename = "type")]
|
|
pub entry_type: String,
|
|
#[serde(default)]
|
|
pub notes: String,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
#[serde(default)]
|
|
pub metadata: Value,
|
|
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub secrets: Option<BTreeMap<String, Value>>,
|
|
}
|
|
|
|
// ── Multi-user models ──────────────────────────────────────────────────────────
|
|
|
|
/// A registered user (created on first OAuth login).
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct User {
|
|
pub id: Uuid,
|
|
pub email: Option<String>,
|
|
pub name: String,
|
|
pub avatar_url: Option<String>,
|
|
/// PBKDF2 salt (32 B). NULL until user sets up passphrase.
|
|
pub key_salt: Option<Vec<u8>>,
|
|
/// AES-256-GCM encryption of the known constant "secrets-mcp-key-check".
|
|
/// Used to verify the passphrase without storing the key itself.
|
|
pub key_check: Option<Vec<u8>>,
|
|
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}.
|
|
pub key_params: Option<serde_json::Value>,
|
|
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
|
|
pub api_key: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// An OAuth account linked to a user.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct OauthAccount {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub provider: String,
|
|
pub provider_id: String,
|
|
pub email: Option<String>,
|
|
pub name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// A single audit log row, optionally scoped to a business user.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct AuditLogEntry {
|
|
pub id: i64,
|
|
pub user_id: Option<Uuid>,
|
|
pub action: String,
|
|
pub folder: String,
|
|
#[serde(rename = "type")]
|
|
#[sqlx(rename = "type")]
|
|
pub entry_type: String,
|
|
pub name: String,
|
|
pub detail: Value,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
|
|
|
|
/// Convert a serde_json Value to a toml Value.
|
|
/// `null` values are filtered out (TOML does not support null).
|
|
/// Mixed-type arrays are serialised as JSON strings.
|
|
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
|
|
match v {
|
|
Value::Null => anyhow::bail!("TOML does not support null values"),
|
|
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
|
|
Value::Number(n) => {
|
|
if let Some(i) = n.as_i64() {
|
|
Ok(toml::Value::Integer(i))
|
|
} else if let Some(f) = n.as_f64() {
|
|
Ok(toml::Value::Float(f))
|
|
} else {
|
|
anyhow::bail!("unsupported number: {}", n)
|
|
}
|
|
}
|
|
Value::String(s) => Ok(toml::Value::String(s.clone())),
|
|
Value::Array(arr) => {
|
|
let items: anyhow::Result<Vec<toml::Value>> =
|
|
arr.iter().map(json_to_toml_value).collect();
|
|
match items {
|
|
Ok(vals) => Ok(toml::Value::Array(vals)),
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
|
|
Ok(toml::Value::String(serde_json::to_string(v)?))
|
|
}
|
|
}
|
|
}
|
|
Value::Object(map) => {
|
|
let mut toml_map = toml::map::Map::new();
|
|
for (k, val) in map {
|
|
if val.is_null() {
|
|
// Skip null entries
|
|
continue;
|
|
}
|
|
match json_to_toml_value(val) {
|
|
Ok(tv) => {
|
|
toml_map.insert(k.clone(), tv);
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
|
|
toml_map
|
|
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
|
|
}
|
|
}
|
|
}
|
|
Ok(toml::Value::Table(toml_map))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert a toml Value back to a serde_json Value.
|
|
pub fn toml_to_json_value(v: &toml::Value) -> Value {
|
|
match v {
|
|
toml::Value::Boolean(b) => Value::Bool(*b),
|
|
toml::Value::Integer(i) => Value::Number((*i).into()),
|
|
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
|
|
.map(Value::Number)
|
|
.unwrap_or(Value::Null),
|
|
toml::Value::String(s) => Value::String(s.clone()),
|
|
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
|
|
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
|
|
toml::Value::Table(map) => {
|
|
let obj: serde_json::Map<String, Value> = map
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
|
|
.collect();
|
|
Value::Object(obj)
|
|
}
|
|
}
|
|
}
|