From f720983328241ca59024e4dc3b6a35a311ac2ea1 Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 21 Mar 2026 16:45:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor(db):=20=E7=A7=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E6=84=8F=E4=B9=89=20actor=EF=BC=8C=E4=BF=AE=E5=A4=8D=20history?= =?UTF-8?q?=20=E5=A4=9A=E7=A7=9F=E6=88=B7=E4=B8=8E=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 entries_history / audit_log / secrets_history 的 actor 列及写入逻辑 - MCP secrets_history 透传当前 user_id - Entry 增加 user_id,search 查询不再用伪 UUID - 迁移:保留 users.api_key,从 api_keys 表回退时生成新明文 key 并删表 - 文档:audit_log auth 语义、API Key 存储说明 Made-with: Cursor --- AGENTS.md | 18 +--- README.md | 7 +- crates/secrets-core/src/audit.rs | 21 ++--- crates/secrets-core/src/db.rs | 89 +++++++++++++++++--- crates/secrets-core/src/models.rs | 2 +- crates/secrets-core/src/service/audit_log.rs | 2 +- crates/secrets-core/src/service/history.rs | 7 +- crates/secrets-core/src/service/search.rs | 6 +- crates/secrets-mcp/src/tools.rs | 5 +- crates/secrets-mcp/src/web.rs | 6 +- 10 files changed, 101 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aab0db8..cdabc8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ secrets/ - **建议库名**:`secrets-mcp`(专用实例,与历史库名区分)。 - **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。 -- **表**:`entries`(含 `user_id`)、`secrets`、`entries_history`、`secrets_history`、`audit_log`、`users`、`oauth_accounts`、`api_keys`,首次连接 **auto-migrate**。 +- **表**:`entries`(含 `user_id`)、`secrets`、`entries_history`、`secrets_history`、`audit_log`、`users`、`oauth_accounts`,首次连接 **auto-migrate**。 ### 表结构(摘录) @@ -60,7 +60,7 @@ secrets ( ) ``` -### users / oauth_accounts / api_keys +### users / oauth_accounts ```sql users ( @@ -71,6 +71,7 @@ users ( key_salt BYTEA, -- PBKDF2 salt(32B),首次设置密码短语时写入 key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语 key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000} + api_key TEXT UNIQUE, -- MCP Bearer token(当前实现为明文存储) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) @@ -83,21 +84,11 @@ oauth_accounts ( ... UNIQUE(provider, provider_id) ) - -api_keys ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(256) NOT NULL, - key_hash VARCHAR(64) NOT NULL UNIQUE, - key_prefix VARCHAR(12) NOT NULL, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) ``` ### audit_log / history -与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。 +与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。`audit_log` 中普通业务事件的 `namespace/kind/name` 对应 entry 坐标;登录类事件固定使用 `namespace='auth'`,此时 `kind/name` 表示认证目标而非 entry 身份。 ### 字段职责 @@ -165,6 +156,5 @@ git tag -l 'secrets-mcp-*' | `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 | -| `USER` | 若写入审计 `actor`,由运行环境提供。 | > `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 diff --git a/README.md b/README.md index 7adc23f..254a897 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ flowchart LR ### 敏感数据传输 - **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器 -- **API Key** 创建时原始 key 仅展示一次,库中只存 SHA-256 哈希 +- **API Key** 当前存放在 `users.api_key`,Dashboard 会明文展示并可重置 - **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化) - **生产环境必须走 HTTPS/TLS** @@ -121,7 +121,7 @@ flowchart LR ## 数据模型 -主表 **`entries`**(`namespace`、`kind`、`name`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`field_name`、`encrypted`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`)、**`oauth_accounts`**、**`api_keys`**。首次连库自动迁移建表。 +主表 **`entries`**(`namespace`、`kind`、`name`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`field_name`、`encrypted`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**。首次连库自动迁移建表。 | 位置 | 字段 | 说明 | |------|------|------| @@ -142,9 +142,10 @@ flowchart LR ## 审计日志 `add`、`update`、`delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。 +其中业务条目事件使用 `[namespace/kind] name` 语义;登录类事件使用 `namespace='auth'`,此时 `kind/name` 表示认证目标(例如 `oauth/google`),不表示某条 secrets entry。 ```sql -SELECT action, namespace, kind, name, actor, detail, created_at +SELECT action, namespace, kind, name, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT 20; diff --git a/crates/secrets-core/src/audit.rs b/crates/secrets-core/src/audit.rs index a739937..f12eec8 100644 --- a/crates/secrets-core/src/audit.rs +++ b/crates/secrets-core/src/audit.rs @@ -5,11 +5,6 @@ use uuid::Uuid; pub const ACTION_LOGIN: &str = "login"; pub const NAMESPACE_AUTH: &str = "auth"; -/// Return the current OS user as the audit actor (falls back to empty string). -pub fn current_actor() -> String { - std::env::var("USER").unwrap_or_default() -} - fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value { json!({ "provider": provider, @@ -27,11 +22,10 @@ pub async fn log_login( client_ip: Option<&str>, user_agent: Option<&str>, ) { - let actor = current_actor(); let detail = login_detail(provider, client_ip, user_agent); let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \ + VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(user_id) .bind(ACTION_LOGIN) @@ -39,14 +33,13 @@ pub async fn log_login( .bind(kind) .bind(provider) .bind(&detail) - .bind(&actor) .execute(pool) .await; if let Err(e) = result { tracing::warn!(error = %e, kind, provider, "failed to write login audit log"); } else { - tracing::debug!(kind, provider, ?user_id, actor, "login audit logged"); + tracing::debug!(kind, provider, ?user_id, "login audit logged"); } } @@ -60,10 +53,9 @@ pub async fn log_tx( name: &str, detail: Value, ) { - let actor = current_actor(); let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \ + VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(user_id) .bind(action) @@ -71,14 +63,13 @@ pub async fn log_tx( .bind(kind) .bind(name) .bind(&detail) - .bind(&actor) .execute(&mut **tx) .await; if let Err(e) = result { tracing::warn!(error = %e, "failed to write audit log"); } else { - tracing::debug!(action, namespace, kind, name, actor, "audit logged"); + tracing::debug!(action, namespace, kind, name, "audit logged"); } } diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index f2fe70a..f22e270 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -3,8 +3,6 @@ use serde_json::Value; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; -use crate::audit::current_actor; - pub async fn create_pool(database_url: &str) -> Result { tracing::debug!("connecting to database"); let pool = PgPoolOptions::new() @@ -73,7 +71,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { kind VARCHAR(64) NOT NULL, name VARCHAR(256) NOT NULL, detail JSONB NOT NULL DEFAULT '{}', - actor VARCHAR(128) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -92,7 +89,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { action VARCHAR(16) NOT NULL, tags TEXT[] NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}', - actor VARCHAR(128) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -105,6 +101,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { 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; + ALTER TABLE entries_history DROP COLUMN IF EXISTS actor; -- ── secrets_history: field-level snapshot ──────────────────────────────── CREATE TABLE IF NOT EXISTS secrets_history ( @@ -115,7 +112,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { field_name VARCHAR(256) NOT NULL, encrypted BYTEA NOT NULL DEFAULT '\x', action VARCHAR(16) NOT NULL, - actor VARCHAR(128) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -124,6 +120,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id ON secrets_history(secret_id); + -- Drop redundant actor column (derivable via entries_history JOIN) + ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; + + -- Drop redundant actor column; user_id already identifies the business user + ALTER TABLE audit_log DROP COLUMN IF EXISTS actor; + -- ── users ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuidv7(), @@ -158,10 +160,75 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { ) .execute(pool) .await?; + restore_plaintext_api_keys(pool).await?; + tracing::debug!("migrations complete"); Ok(()) } +async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> { + let has_users_api_key: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'api_key' + )", + ) + .fetch_one(pool) + .await?; + + if !has_users_api_key { + sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT") + .execute(pool) + .await?; + sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL") + .execute(pool) + .await?; + } + + let has_api_keys_table: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'api_keys' + )", + ) + .fetch_one(pool) + .await?; + + if !has_api_keys_table { + return Ok(()); + } + + #[derive(sqlx::FromRow)] + struct UserWithoutKey { + id: uuid::Uuid, + } + + let users_without_key: Vec = + sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)") + .fetch_all(pool) + .await?; + + for user in users_without_key { + let new_key = crate::service::api_key::generate_api_key(); + sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") + .bind(&new_key) + .bind(user.id) + .execute(pool) + .await?; + } + + sqlx::query("DROP TABLE IF EXISTS api_keys") + .execute(pool) + .await?; + + Ok(()) +} + // ── Entry-level history snapshot ───────────────────────────────────────────── pub struct EntrySnapshotParams<'a> { @@ -180,11 +247,10 @@ pub async fn snapshot_entry_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, p: EntrySnapshotParams<'_>, ) -> Result<()> { - let actor = current_actor(); sqlx::query( "INSERT INTO entries_history \ - (entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + (entry_id, namespace, kind, name, version, action, tags, metadata, user_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) .bind(p.entry_id) .bind(p.namespace) @@ -194,7 +260,6 @@ pub async fn snapshot_entry_history( .bind(p.action) .bind(p.tags) .bind(p.metadata) - .bind(&actor) .bind(p.user_id) .execute(&mut **tx) .await?; @@ -216,11 +281,10 @@ pub async fn snapshot_secret_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, p: SecretSnapshotParams<'_>, ) -> Result<()> { - let actor = current_actor(); sqlx::query( "INSERT INTO secrets_history \ - (entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", + (entry_id, secret_id, entry_version, field_name, encrypted, action) \ + VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(p.entry_id) .bind(p.secret_id) @@ -228,7 +292,6 @@ pub async fn snapshot_secret_history( .bind(p.field_name) .bind(p.encrypted) .bind(p.action) - .bind(&actor) .execute(&mut **tx) .await?; Ok(()) diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index 6b53a58..9f44e4f 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -9,6 +9,7 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Entry { pub id: Uuid, + pub user_id: Option, pub namespace: String, pub kind: String, pub name: String, @@ -184,7 +185,6 @@ pub struct AuditLogEntry { pub kind: String, pub name: String, pub detail: Value, - pub actor: String, pub created_at: DateTime, } diff --git a/crates/secrets-core/src/service/audit_log.rs b/crates/secrets-core/src/service/audit_log.rs index 131fce7..4acc00c 100644 --- a/crates/secrets-core/src/service/audit_log.rs +++ b/crates/secrets-core/src/service/audit_log.rs @@ -8,7 +8,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result, } let rows: Vec = if let Some(uid) = user_id { sqlx::query_as( - "SELECT version, action, actor, created_at FROM entries_history \ + "SELECT version, action, created_at FROM entries_history \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \ ORDER BY id DESC LIMIT $5", ) @@ -42,7 +40,7 @@ pub async fn run( .await? } else { sqlx::query_as( - "SELECT version, action, actor, created_at FROM entries_history \ + "SELECT version, action, 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", ) @@ -59,7 +57,6 @@ pub async fn run( .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()) diff --git a/crates/secrets-core/src/service/search.rs b/crates/secrets-core/src/service/search.rs index a747068..c788b10 100644 --- a/crates/secrets-core/src/service/search.rs +++ b/crates/secrets-core/src/service/search.rs @@ -131,7 +131,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result, namespace: String, kind: String, name: String, @@ -228,6 +227,7 @@ impl From for Entry { fn from(r: EntryRaw) -> Self { Entry { id: r.id, + user_id: r.user_id, namespace: r.namespace, kind: r.kind, name: r.name, diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index cba871b..b656856 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -473,15 +473,16 @@ impl SecretsService { async fn secrets_history( &self, Parameters(input): Parameters, - _ctx: RequestContext, + ctx: RequestContext, ) -> Result { + let user_id = Self::user_id_from_ctx(&ctx)?; let result = svc_history( &self.pool, &input.namespace, &input.kind, &input.name, input.limit.unwrap_or(20), - None, + user_id, ) .await .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index dc1385f..34e975d 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -321,11 +321,6 @@ where StatusCode::INTERNAL_SERVER_ERROR })?; - // Ensure the user has an API key (auto-creates on first login). - if let Err(e) = ensure_api_key(&state.pool, user.id).await { - tracing::warn!(error = %e, "failed to ensure api key for user"); - } - session .insert(SESSION_USER_ID, user.id.to_string()) .await @@ -642,6 +637,7 @@ fn render_template(tmpl: T) -> Result { } fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String { + // Auth events reuse kind/name as a provider-scoped target, not an entry identity. if namespace == "auth" { format!("{}/{}", kind, name) } else {