diff --git a/.gitignore b/.gitignore index 08ca2ea..cab6270 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .cursor/ # Google OAuth 下载的 JSON 凭据文件 client_secret_*.apps.googleusercontent.com.json +*.pem \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ae77f9e..d437cf8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,23 +1,19 @@ { "version": "2.0.0", "tasks": [ - { - "label": "build", - "type": "shell", - "command": "cargo build --workspace --locked", - "group": { "kind": "build", "isDefault": true } - }, { "label": "mcp: build", "type": "shell", "command": "cargo build --locked -p secrets-mcp", - "group": "build" + "group": "build", + "options": { + "envFile": "${workspaceFolder}/.env" + } }, { "label": "mcp: run", "type": "shell", "command": "cargo run --locked -p secrets-mcp", - "dependsOn": "mcp: build", "options": { "envFile": "${workspaceFolder}/.env" } diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 0a4ed93..9104e03 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -99,6 +99,11 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_entries_history_ns_kind_name ON entries_history(namespace, kind, name, version DESC); + -- Backfill: add user_id to entries_history for multi-tenant isolation + ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID; + CREATE INDEX IF NOT EXISTS idx_entries_history_user_id + ON entries_history(user_id) WHERE user_id IS NOT NULL; + -- ── secrets_history: field-level snapshot ──────────────────────────────── CREATE TABLE IF NOT EXISTS secrets_history ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -159,6 +164,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { pub struct EntrySnapshotParams<'a> { pub entry_id: uuid::Uuid, + pub user_id: Option, pub namespace: &'a str, pub kind: &'a str, pub name: &'a str, @@ -175,8 +181,8 @@ pub async fn snapshot_entry_history( let actor = current_actor(); sqlx::query( "INSERT INTO entries_history \ - (entry_id, namespace, kind, name, version, action, tags, metadata, actor) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + (entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(p.entry_id) .bind(p.namespace) @@ -187,6 +193,7 @@ pub async fn snapshot_entry_history( .bind(p.tags) .bind(p.metadata) .bind(&actor) + .bind(p.user_id) .execute(&mut **tx) .await?; Ok(()) diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs index 6649c3d..6100bb9 100644 --- a/crates/secrets-core/src/service/add.rs +++ b/crates/secrets-core/src/service/add.rs @@ -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 { diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs index 9aa1d95..1da8918 100644 --- a/crates/secrets-core/src/service/delete.rs +++ b/crates/secrets-core/src/service/delete.rs @@ -33,7 +33,15 @@ pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result { delete_bulk( @@ -53,8 +61,48 @@ async fn delete_one( namespace: &str, kind: &str, name: &str, + dry_run: bool, user_id: Option, ) -> Result { + 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 = 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, ) -> Result<()> { if let Err(e) = db::snapshot_entry_history( tx, db::EntrySnapshotParams { entry_id: row.id, + user_id, namespace, kind, name, diff --git a/crates/secrets-core/src/service/env_map.rs b/crates/secrets-core/src/service/env_map.rs index 264cd57..86adccf 100644 --- a/crates/secrets-core/src/service/env_map.rs +++ b/crates/secrets-core/src/service/env_map.rs @@ -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) } } diff --git a/crates/secrets-core/src/service/history.rs b/crates/secrets-core/src/service/history.rs index 5908aa1..71763c2 100644 --- a/crates/secrets-core/src/service/history.rs +++ b/crates/secrets-core/src/service/history.rs @@ -17,7 +17,7 @@ pub async fn run( kind: &str, name: &str, limit: u32, - _user_id: Option, + user_id: Option, ) -> Result> { #[derive(sqlx::FromRow)] struct Row { @@ -27,17 +27,32 @@ pub async fn run( created_at: chrono::DateTime, } - let rows: Vec = 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 = 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() diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs index 1964673..1469bb4 100644 --- a/crates/secrets-core/src/service/rollback.rs +++ b/crates/secrets-core/src/service/rollback.rs @@ -21,7 +21,7 @@ pub async fn run( name: &str, to_version: Option, master_key: &[u8; 32], - _user_id: Option, + user_id: Option, ) -> Result { #[derive(sqlx::FromRow)] struct EntryHistoryRow { @@ -33,21 +33,49 @@ pub async fn run( } let snap: Option = 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, action: String, } let field_snaps: Vec = 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, metadata: Value, } - let live: Option = 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 = 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( diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index 7dd0df3..8a4baac 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -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, diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 73a1482..cba871b 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -555,9 +555,9 @@ impl SecretsService { } #[tool( - description = "Preview the environment variable mapping that would be injected when \ - running a command. Requires X-Encryption-Key header. \ - Shows variable names and sources, useful for debugging." + description = "Build the environment variable map from entry secrets with decrypted \ + plaintext values. Requires X-Encryption-Key header. \ + Returns a JSON object of VAR_NAME -> plaintext_value ready for injection." )] async fn secrets_env_map( &self, diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 9137e94..57d0ac5 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -1,8 +1,9 @@ use askama::Template; use axum::{ Json, Router, + body::Body, extract::{Path, Query, State}, - http::StatusCode, + http::{StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; @@ -33,6 +34,7 @@ const SESSION_LOGIN_PROVIDER: &str = "login_provider"; #[template(path = "login.html")] struct LoginTemplate { has_google: bool, + version: &'static str, } #[derive(Template)] @@ -42,6 +44,7 @@ struct DashboardTemplate { user_email: String, has_passphrase: bool, base_url: String, + version: &'static str, } // ── App state helpers ───────────────────────────────────────────────────────── @@ -63,6 +66,11 @@ async fn current_user_id(session: &Session) -> Option { pub fn web_router() -> Router { Router::new() + .route("/favicon.svg", get(favicon_svg)) + .route( + "/favicon.ico", + get(|| async { Redirect::permanent("/favicon.svg") }), + ) .route("/", get(login_page)) .route("/auth/google", get(auth_google)) .route("/auth/google/callback", get(auth_google_callback)) @@ -80,6 +88,15 @@ pub fn web_router() -> Router { .route("/api/apikey/regenerate", post(api_apikey_regenerate)) } +async fn favicon_svg() -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/svg+xml") + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(include_str!("../static/favicon.svg"))) + .expect("favicon response") +} + // ── Login page ──────────────────────────────────────────────────────────────── async fn login_page( @@ -92,6 +109,7 @@ async fn login_page( let tmpl = LoginTemplate { has_google: state.google_config.is_some(), + version: env!("CARGO_PKG_VERSION"), }; render_template(tmpl) } @@ -272,20 +290,24 @@ async fn dashboard( State(state): State, session: Session, ) -> Result { - let user_id = current_user_id(&session) - .await - .ok_or(StatusCode::UNAUTHORIZED)?; + let Some(user_id) = current_user_id(&session).await else { + return Ok(Redirect::to("/").into_response()); + }; - let user = get_user_by_id(&state.pool, user_id) + let user = match get_user_by_id(&state.pool, user_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; + { + Some(u) => u, + None => return Ok(Redirect::to("/").into_response()), + }; let tmpl = DashboardTemplate { user_name: user.name.clone(), user_email: user.email.clone().unwrap_or_default(), has_passphrase: user.key_salt.is_some(), base_url: state.base_url.clone(), + version: env!("CARGO_PKG_VERSION"), }; render_template(tmpl) diff --git a/crates/secrets-mcp/static/favicon.svg b/crates/secrets-mcp/static/favicon.svg new file mode 100644 index 0000000..ac92563 --- /dev/null +++ b/crates/secrets-mcp/static/favicon.svg @@ -0,0 +1,3 @@ + + S + diff --git a/crates/secrets-mcp/templates/dashboard.html b/crates/secrets-mcp/templates/dashboard.html index bf9eae2..3f279f9 100644 --- a/crates/secrets-mcp/templates/dashboard.html +++ b/crates/secrets-mcp/templates/dashboard.html @@ -3,6 +3,7 @@ + Secrets @@ -140,12 +172,6 @@
说明
API Key 用于身份认证,告诉服务端“你是谁”。
-
- Cursor - Claude Code - Codex - Gemini CLI -
@@ -156,11 +182,25 @@
+ +

         

- + - +
+ +
{{ version }}
@@ -229,11 +278,25 @@

更换密码

- +
+ + +
- +
+ + +