Compare commits

..

7 Commits

Author SHA1 Message Date
voson
0b57605103 feat(secrets-mcp): MCP 请求日志、探测 404 与资源元数据
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 新增 logging 中间件:记录 client_ip、ua、JSON-RPC、tool 等
- tools 各入口/出口结构化日志
- 探测型 404(/.well-known、GET /mcp)降为 debug
- /.well-known/oauth-protected-resource 最小元数据
- secrets-mcp 0.1.11

Made-with: Cursor
2026-03-21 17:57:10 +08:00
voson
8b191937cd docs(AGENTS): 精简提交/推送规则第4条
Made-with: Cursor
2026-03-21 16:56:06 +08:00
voson
11c936a5b8 docs(AGENTS): 明确提交/推送前必须检查版本号与运行 fmt/clippy/test
Made-with: Cursor
2026-03-21 16:48:47 +08:00
voson
b6349dd1c8 chore(secrets-mcp): bump version to 0.1.10
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m57s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 16:46:33 +08:00
voson
f720983328 refactor(db): 移除无意义 actor,修复 history 多租户与模型
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has started running
- 删除 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
2026-03-21 16:45:50 +08:00
voson
7bd0603dc6 chore(secrets-mcp): bump version to 0.1.9
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 12:25:38 +08:00
voson
17a95bea5b refactor(audit): 移除旧格式兼容,user_id 统一走列字段
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- audit_log 查询去掉 detail->>'user_id' 回退分支
- login_detail 不再冗余写入 user_id 到 detail JSON
- 迁移 SQL 去掉多余的 ALTER TABLE ADD COLUMN

Made-with: Cursor
2026-03-21 12:24:00 +08:00
14 changed files with 596 additions and 93 deletions

View File

@@ -2,12 +2,13 @@
本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp` 本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
## 提交 / 发版硬规则(优先于下文) ## 提交 / 推送硬规则(优先于下文)
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 发版前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'` 2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`
3. 若当前版本对应 tag 已存在,默认允许复用现有 tag 继续构建;仅在需要新的发布版本时再 bump `version``cargo build` 同步 `Cargo.lock` 3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
4. 提交前优先运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。
## 项目结构 ## 项目结构
@@ -28,7 +29,7 @@ secrets/
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。 - **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。 - **连接**:环境变量 **`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 +61,7 @@ secrets (
) )
``` ```
### users / oauth_accounts / api_keys ### users / oauth_accounts
```sql ```sql
users ( users (
@@ -71,6 +72,7 @@ users (
key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入 key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语 key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000} key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token当前实现为明文存储
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) )
@@ -83,21 +85,11 @@ oauth_accounts (
... ...
UNIQUE(provider, provider_id) 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 / 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 +157,5 @@ git tag -l 'secrets-mcp-*'
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 |
| `USER` | 若写入审计 `actor`,由运行环境提供。 |
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 > `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

3
Cargo.lock generated
View File

@@ -1949,7 +1949,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",
@@ -2700,6 +2700,7 @@ dependencies = [
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]

View File

@@ -77,7 +77,7 @@ flowchart LR
### 敏感数据传输 ### 敏感数据传输
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器 - **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 创建时原始 key 仅展示一次,库中只存 SHA-256 哈希 - **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化) - **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/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 明文)。 `add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。
其中业务条目事件使用 `[namespace/kind] name` 语义;登录类事件使用 `namespace='auth'`,此时 `kind/name` 表示认证目标(例如 `oauth/google`),不表示某条 secrets entry。
```sql ```sql
SELECT action, namespace, kind, name, actor, detail, created_at SELECT action, namespace, kind, name, detail, created_at
FROM audit_log FROM audit_log
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20; LIMIT 20;

View File

@@ -5,19 +5,8 @@ use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login"; pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth"; pub const NAMESPACE_AUTH: &str = "auth";
/// Return the current OS user as the audit actor (falls back to empty string). fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
pub fn current_actor() -> String {
std::env::var("USER").unwrap_or_default()
}
fn login_detail(
user_id: Uuid,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
) -> Value {
json!({ json!({
"user_id": user_id,
"provider": provider, "provider": provider,
"client_ip": client_ip, "client_ip": client_ip,
"user_agent": user_agent, "user_agent": user_agent,
@@ -33,11 +22,10 @@ pub async fn log_login(
client_ip: Option<&str>, client_ip: Option<&str>,
user_agent: Option<&str>, user_agent: Option<&str>,
) { ) {
let actor = current_actor(); let detail = login_detail(provider, client_ip, user_agent);
let detail = login_detail(user_id, provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query( let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6, $7)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(user_id) .bind(user_id)
.bind(ACTION_LOGIN) .bind(ACTION_LOGIN)
@@ -45,14 +33,13 @@ pub async fn log_login(
.bind(kind) .bind(kind)
.bind(provider) .bind(provider)
.bind(&detail) .bind(&detail)
.bind(&actor)
.execute(pool) .execute(pool)
.await; .await;
if let Err(e) = result { if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log"); tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
} else { } else {
tracing::debug!(kind, provider, ?user_id, actor, "login audit logged"); tracing::debug!(kind, provider, ?user_id, "login audit logged");
} }
} }
@@ -66,10 +53,9 @@ pub async fn log_tx(
name: &str, name: &str,
detail: Value, detail: Value,
) { ) {
let actor = current_actor();
let result: Result<_, sqlx::Error> = sqlx::query( let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6, $7)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(user_id) .bind(user_id)
.bind(action) .bind(action)
@@ -77,14 +63,13 @@ pub async fn log_tx(
.bind(kind) .bind(kind)
.bind(name) .bind(name)
.bind(&detail) .bind(&detail)
.bind(&actor)
.execute(&mut **tx) .execute(&mut **tx)
.await; .await;
if let Err(e) = result { if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log"); tracing::warn!(error = %e, "failed to write audit log");
} else { } else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged"); tracing::debug!(action, namespace, kind, name, "audit logged");
} }
} }
@@ -94,10 +79,8 @@ mod tests {
#[test] #[test]
fn login_detail_includes_expected_fields() { fn login_detail_includes_expected_fields() {
let user_id = Uuid::nil(); let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
let detail = login_detail(user_id, "google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["user_id"], json!(user_id));
assert_eq!(detail["provider"], "google"); assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1"); assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0"); assert_eq!(detail["user_agent"], "Mozilla/5.0");

View File

@@ -3,8 +3,6 @@ use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use crate::audit::current_actor;
pub async fn create_pool(database_url: &str) -> Result<PgPool> { pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database"); tracing::debug!("connecting to database");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
@@ -73,11 +71,9 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
kind VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind); CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
@@ -93,7 +89,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
action VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -106,6 +101,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID; ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL; 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 ──────────────────────────────── -- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history ( CREATE TABLE IF NOT EXISTS secrets_history (
@@ -116,7 +112,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
field_name VARCHAR(256) NOT NULL, field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL,
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -125,6 +120,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON 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 ───────────────────────────────────────────────────────────────── -- ── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
@@ -159,10 +160,75 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
) )
.execute(pool) .execute(pool)
.await?; .await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete"); tracing::debug!("migrations complete");
Ok(()) 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<UserWithoutKey> =
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 ───────────────────────────────────────────── // ── Entry-level history snapshot ─────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> { pub struct EntrySnapshotParams<'a> {
@@ -181,11 +247,10 @@ pub async fn snapshot_entry_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: EntrySnapshotParams<'_>, p: EntrySnapshotParams<'_>,
) -> Result<()> { ) -> Result<()> {
let actor = current_actor();
sqlx::query( sqlx::query(
"INSERT INTO entries_history \ "INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \ (entry_id, namespace, kind, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
) )
.bind(p.entry_id) .bind(p.entry_id)
.bind(p.namespace) .bind(p.namespace)
@@ -195,7 +260,6 @@ pub async fn snapshot_entry_history(
.bind(p.action) .bind(p.action)
.bind(p.tags) .bind(p.tags)
.bind(p.metadata) .bind(p.metadata)
.bind(&actor)
.bind(p.user_id) .bind(p.user_id)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
@@ -217,11 +281,10 @@ pub async fn snapshot_secret_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SecretSnapshotParams<'_>, p: SecretSnapshotParams<'_>,
) -> Result<()> { ) -> Result<()> {
let actor = current_actor();
sqlx::query( sqlx::query(
"INSERT INTO secrets_history \ "INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \ (entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6, $7)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(p.entry_id) .bind(p.entry_id)
.bind(p.secret_id) .bind(p.secret_id)
@@ -229,7 +292,6 @@ pub async fn snapshot_secret_history(
.bind(p.field_name) .bind(p.field_name)
.bind(p.encrypted) .bind(p.encrypted)
.bind(p.action) .bind(p.action)
.bind(&actor)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
Ok(()) Ok(())

View File

@@ -9,6 +9,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry { pub struct Entry {
pub id: Uuid, pub id: Uuid,
pub user_id: Option<Uuid>,
pub namespace: String, pub namespace: String,
pub kind: String, pub kind: String,
pub name: String, pub name: String,
@@ -184,7 +185,6 @@ pub struct AuditLogEntry {
pub kind: String, pub kind: String,
pub name: String, pub name: String,
pub detail: Value, pub detail: Value,
pub actor: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }

View File

@@ -8,9 +8,9 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<V
let limit = limit.clamp(1, 200); let limit = limit.clamp(1, 200);
let rows = sqlx::query_as( let rows = sqlx::query_as(
"SELECT id, user_id, action, namespace, kind, name, detail, actor, created_at \ "SELECT id, user_id, action, namespace, kind, name, detail, created_at \
FROM audit_log \ FROM audit_log \
WHERE user_id = $1 OR (user_id IS NULL AND detail->>'user_id' = $1::text) \ WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \ ORDER BY created_at DESC, id DESC \
LIMIT $2", LIMIT $2",
) )

View File

@@ -7,7 +7,6 @@ use uuid::Uuid;
pub struct HistoryEntry { pub struct HistoryEntry {
pub version: i64, pub version: i64,
pub action: String, pub action: String,
pub actor: String,
pub created_at: String, pub created_at: String,
} }
@@ -23,13 +22,12 @@ pub async fn run(
struct Row { struct Row {
version: i64, version: i64,
action: String, action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
let rows: Vec<Row> = if let Some(uid) = user_id { let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as( 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 \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5", ORDER BY id DESC LIMIT $5",
) )
@@ -42,7 +40,7 @@ pub async fn run(
.await? .await?
} else { } else {
sqlx::query_as( 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 \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4", ORDER BY id DESC LIMIT $4",
) )
@@ -59,7 +57,6 @@ pub async fn run(
.map(|r| HistoryEntry { .map(|r| HistoryEntry {
version: r.version, version: r.version,
action: r.action, action: r.action,
actor: r.actor,
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
}) })
.collect()) .collect())

View File

@@ -131,7 +131,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
}; };
let sql = format!( let sql = format!(
"SELECT id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, \ "SELECT id, user_id, \
namespace, kind, name, tags, metadata, version, created_at, updated_at \ namespace, kind, name, tags, metadata, version, created_at, updated_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}" FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
); );
@@ -212,8 +212,7 @@ pub async fn fetch_secrets_for_entries(
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct EntryRaw { struct EntryRaw {
id: Uuid, id: Uuid,
#[allow(dead_code)] // Selected for row shape; Entry model has no user_id field user_id: Option<Uuid>,
user_id: Uuid,
namespace: String, namespace: String,
kind: String, kind: String,
name: String, name: String,
@@ -228,6 +227,7 @@ impl From<EntryRaw> for Entry {
fn from(r: EntryRaw) -> Self { fn from(r: EntryRaw) -> Self {
Entry { Entry {
id: r.id, id: r.id,
user_id: r.user_id,
namespace: r.namespace, namespace: r.namespace,
kind: r.kind, kind: r.kind,
name: r.name, name: r.name,

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.8" version = "0.1.11"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
@@ -17,7 +17,7 @@ rmcp = { version = "1", features = ["server", "macros", "transport-streamable-ht
axum = "0.8" axum = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] } axum-extra = { version = "0.10", features = ["typed-header"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-sessions = "0.14" tower-sessions = "0.14"
# OAuth (manual token exchange via reqwest) # OAuth (manual token exchange via reqwest)

View File

@@ -0,0 +1,249 @@
use std::net::SocketAddr;
use std::time::Instant;
use axum::{
body::{Body, Bytes, to_bytes},
extract::{ConnectInfo, Request},
http::{
HeaderMap, Method,
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
},
middleware::Next,
response::Response,
};
/// Axum middleware that logs structured info for every HTTP request.
///
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
/// tool_name, jsonrpc_id, mcp_session, batch_size.
///
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
/// are never logged.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let ip = client_ip(&req);
let ua = header_str(req.headers(), USER_AGENT);
let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
let mcp_session = req
.headers()
.get("mcp-session-id")
.or_else(|| req.headers().get("x-mcp-session"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json"))
.unwrap_or(false);
let start = Instant::now();
// For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
// We cap at 512 KiB to avoid buffering large payloads.
if is_mcp_post && is_json {
let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 {
let (parts, body) = req.into_parts();
match to_bytes(body, 512 * 1024).await {
Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&bytes);
let req = Request::from_parts(parts, Body::from(bytes));
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
log_mcp_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
mcp_session.as_deref(),
&rpc,
);
return resp;
}
Err(e) => {
tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
// Reconstruct with empty body; request was consumed — return 500.
// This branch is highly unlikely in practice.
let resp = next.run(Request::from_parts(parts, Body::empty())).await;
return resp;
}
}
}
}
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
// Known client probe patterns that legitimately 404 — downgrade to debug to
// avoid noise in production logs. These are:
// • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
// • GET /mcp → 404 — old SSE-transport compatibility probe by clients
let is_expected_probe_404 = status == 404
&& (path.starts_with("/.well-known/")
|| (method == Method::GET && path.starts_with("/mcp")));
if is_expected_probe_404 {
tracing::debug!(
method = method.as_str(),
path,
status,
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
"probe request (not found — expected)",
);
} else {
log_http_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
);
}
resp
}
// ── Logging helpers ───────────────────────────────────────────────────────────
fn log_http_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
"http request",
);
}
#[allow(clippy::too_many_arguments)]
fn log_mcp_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
mcp_session: Option<&str>,
rpc: &JsonRpcMeta,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
mcp_session,
jsonrpc = rpc.rpc_method.as_deref(),
tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size,
"mcp request",
);
}
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct JsonRpcMeta {
request_id: Option<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
}
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return JsonRpcMeta::default();
};
if let Some(arr) = value.as_array() {
// Batch request: summarise method(s) from first element only
let first = arr.first().map(parse_single).unwrap_or_default();
return JsonRpcMeta {
batch_size: Some(arr.len()),
..first
};
}
parse_single(&value)
}
fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
let request_id = value.get("id").and_then(json_to_string);
let rpc_method = value
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_name = value
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
JsonRpcMeta {
request_id,
rpc_method,
tool_name,
batch_size: None,
}
}
fn json_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
// ── Header helpers ────────────────────────────────────────────────────────────
fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn client_ip(req: &Request) -> Option<String> {
if let Some(first) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
}

View File

@@ -1,4 +1,5 @@
mod auth; mod auth;
mod logging;
mod oauth; mod oauth;
mod tools; mod tools;
mod web; mod web;
@@ -53,7 +54,8 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_mcp=info".into()), EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
) )
.init(); .init();
@@ -120,6 +122,9 @@ async fn main() -> Result<()> {
let router = Router::new() let router = Router::new()
.merge(web::web_router()) .merge(web::web_router())
.nest_service("/mcp", mcp_service) .nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn(
logging::request_logging_middleware,
))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
pool, pool,
auth::bearer_auth_middleware, auth::bearer_auth_middleware,

View File

@@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use anyhow::Result; use anyhow::Result;
use rmcp::{ use rmcp::{
@@ -257,7 +258,17 @@ impl SecretsService {
Parameters(input): Parameters<SearchInput>, Parameters(input): Parameters<SearchInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?; let user_id = Self::user_id_from_ctx(&ctx)?;
tracing::info!(
tool = "secrets_search",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
name = input.name.as_deref(),
query = input.query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let result = svc_search( let result = svc_search(
&self.pool, &self.pool,
@@ -274,7 +285,10 @@ impl SecretsService {
}, },
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_search", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
let summary = input.summary.unwrap_or(false); let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result let entries: Vec<serde_json::Value> = result
@@ -312,6 +326,14 @@ impl SecretsService {
}) })
.collect(); .collect();
let count = entries.len();
tracing::info!(
tool = "secrets_search",
?user_id,
result_count = count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()); let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -326,7 +348,17 @@ impl SecretsService {
Parameters(input): Parameters<GetSecretInput>, Parameters(input): Parameters<GetSecretInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_get",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
field = input.field.as_deref(),
"tool call start",
);
if let Some(field_name) = &input.field { if let Some(field_name) = &input.field {
let value = get_secret_field( let value = get_secret_field(
@@ -339,8 +371,17 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_get",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let result = serde_json::json!({ field_name: value }); let result = serde_json::json!({ field_name: value });
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
@@ -354,8 +395,19 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
let count = secrets.len();
tracing::info!(
tool = "secrets_get",
?user_id,
field_count = count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&secrets).unwrap_or_default(); let json = serde_json::to_string_pretty(&secrets).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -371,7 +423,16 @@ impl SecretsService {
Parameters(input): Parameters<AddInput>, Parameters(input): Parameters<AddInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default(); let meta = input.meta.unwrap_or_default();
@@ -391,8 +452,20 @@ impl SecretsService {
&user_key, &user_key,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_add", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -406,7 +479,16 @@ impl SecretsService {
Parameters(input): Parameters<UpdateInput>, Parameters(input): Parameters<UpdateInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let add_tags = input.add_tags.unwrap_or_default(); let add_tags = input.add_tags.unwrap_or_default();
let remove_tags = input.remove_tags.unwrap_or_default(); let remove_tags = input.remove_tags.unwrap_or_default();
@@ -432,8 +514,20 @@ impl SecretsService {
&user_key, &user_key,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_update", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -447,7 +541,17 @@ impl SecretsService {
Parameters(input): Parameters<DeleteInput>, Parameters(input): Parameters<DeleteInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?; let user_id = Self::user_id_from_ctx(&ctx)?;
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
kind = input.kind.as_deref(),
name = input.name.as_deref(),
dry_run = input.dry_run.unwrap_or(false),
"tool call start",
);
let result = svc_delete( let result = svc_delete(
&self.pool, &self.pool,
@@ -460,8 +564,18 @@ impl SecretsService {
}, },
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_delete", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -473,19 +587,39 @@ impl SecretsService {
async fn secrets_history( async fn secrets_history(
&self, &self,
Parameters(input): Parameters<HistoryInput>, Parameters(input): Parameters<HistoryInput>,
_ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?;
tracing::info!(
tool = "secrets_history",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let result = svc_history( let result = svc_history(
&self.pool, &self.pool,
&input.namespace, &input.namespace,
&input.kind, &input.kind,
&input.name, &input.name,
input.limit.unwrap_or(20), input.limit.unwrap_or(20),
None, user_id,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_history", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_history",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -499,7 +633,17 @@ impl SecretsService {
Parameters(input): Parameters<RollbackInput>, Parameters(input): Parameters<RollbackInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_rollback",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
to_version = input.to_version,
"tool call start",
);
let result = svc_rollback( let result = svc_rollback(
&self.pool, &self.pool,
@@ -511,8 +655,17 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_rollback", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
tracing::info!(
tool = "secrets_rollback",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -526,9 +679,18 @@ impl SecretsService {
Parameters(input): Parameters<ExportInput>, Parameters(input): Parameters<ExportInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let format = input.format.as_deref().unwrap_or("json"); let format = input.format.as_deref().unwrap_or("json");
tracing::info!(
tool = "secrets_export",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
format,
"tool call start",
);
let data = svc_export( let data = svc_export(
&self.pool, &self.pool,
@@ -544,13 +706,23 @@ impl SecretsService {
Some(&user_key), Some(&user_key),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_export", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
let serialized = format let serialized = format
.parse::<secrets_core::models::ExportFormat>() .parse::<secrets_core::models::ExportFormat>()
.and_then(|fmt| fmt.serialize(&data)) .and_then(|fmt| fmt.serialize(&data))
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
tracing::info!(
tool = "secrets_export",
?user_id,
entry_count = data.entries.len(),
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
Ok(CallToolResult::success(vec![Content::text(serialized)])) Ok(CallToolResult::success(vec![Content::text(serialized)]))
} }
@@ -564,9 +736,18 @@ impl SecretsService {
Parameters(input): Parameters<EnvMapInput>, Parameters(input): Parameters<EnvMapInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let only_fields = input.only_fields.unwrap_or_default(); let only_fields = input.only_fields.unwrap_or_default();
tracing::info!(
tool = "secrets_env_map",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
prefix = input.prefix.as_deref().unwrap_or(""),
"tool call start",
);
let env_map = secrets_core::service::env_map::build_env_map( let env_map = secrets_core::service::env_map::build_env_map(
&self.pool, &self.pool,
@@ -580,8 +761,19 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| {
tracing::warn!(tool = "secrets_env_map", ?user_id, error = %e, "tool call failed");
rmcp::ErrorData::internal_error(e.to_string(), None)
})?;
let entry_count = env_map.len();
tracing::info!(
tool = "secrets_env_map",
?user_id,
entry_count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&env_map).unwrap_or_default(); let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }

View File

@@ -117,6 +117,10 @@ pub fn web_router() -> Router<AppState> {
"/favicon.ico", "/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }), get(|| async { Redirect::permanent("/favicon.svg") }),
) )
.route(
"/.well-known/oauth-protected-resource",
get(oauth_protected_resource_metadata),
)
.route("/", get(login_page)) .route("/", get(login_page))
.route("/auth/google", get(auth_google)) .route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback)) .route("/auth/google/callback", get(auth_google_callback))
@@ -321,11 +325,6 @@ where
StatusCode::INTERNAL_SERVER_ERROR 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 session
.insert(SESSION_USER_ID, user.id.to_string()) .insert(SESSION_USER_ID, user.id.to_string())
.await .await
@@ -631,6 +630,28 @@ async fn api_apikey_regenerate(
Ok(Json(ApiKeyResponse { api_key })) Ok(Json(ApiKeyResponse { api_key }))
} }
// ── OAuth / Well-known ────────────────────────────────────────────────────────
/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
///
/// Advertises that this server accepts Bearer tokens in the `Authorization`
/// header. We deliberately omit `authorization_servers` because this service
/// issues its own API keys (no external OAuth AS is involved). MCP clients
/// that probe this endpoint will see the resource identifier and stop looking
/// for a delegated OAuth flow.
async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> impl IntoResponse {
let body = serde_json::json!({
"resource": state.base_url,
"bearer_methods_supported": ["header"],
"resource_documentation": format!("{}/dashboard", state.base_url),
});
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
axum::Json(body),
)
}
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helper ────────────────────────────────────────────────────────────────────
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> { fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
@@ -642,6 +663,7 @@ fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
} }
fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String { 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" { if namespace == "auth" {
format!("{}/{}", kind, name) format!("{}/{}", kind, name)
} else { } else {