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:
249
crates/secrets-core/src/models.rs
Normal file
249
crates/secrets-core/src/models.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
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, …).
|
||||
/// Sensitive fields are stored separately in `secrets`.
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Entry {
|
||||
pub id: Uuid,
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: 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 tags: Vec<String>,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
/// 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 namespace: String,
|
||||
pub kind: String,
|
||||
pub name: 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>,
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user