Compare commits
44 Commits
c3c536200e
...
secrets-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5865b958 | ||
|
|
34093b0e23 | ||
|
|
0bf06bbc73 | ||
|
|
f86d12b80e | ||
|
|
43d6164a15 | ||
|
|
1b2fbdae4d | ||
|
|
ab1e3329b9 | ||
|
|
c3b1a0df1a | ||
|
|
d772066210 | ||
|
|
2c7dbf890b | ||
|
|
8c49316923 | ||
|
|
cf93488c6a | ||
| 137a4d42b0 | |||
|
|
ff2ea91e72 | ||
|
|
574c1c9967 | ||
|
|
98d69f5f12 | ||
|
|
089d0b4b58 | ||
|
|
10da51c203 | ||
|
|
bc8995cf71 | ||
|
|
5333b863c5 | ||
|
|
6fde982c20 | ||
|
|
a2a80a1744 | ||
| dfe282095c | |||
|
|
59084a409d | ||
|
|
b0fcb83592 | ||
|
|
8942718641 | ||
|
|
53d53ff96a | ||
|
|
cab234cfcb | ||
|
|
e0fee639c1 | ||
|
|
7c53bfb782 | ||
|
|
63cb3a8216 | ||
|
|
2b994141b8 | ||
|
|
9d6ac5c13a | ||
|
|
1860cce86c | ||
| dd24f7cc44 | |||
|
|
aefad33870 | ||
|
|
0ffb81e57f | ||
|
|
4a1654c820 | ||
|
|
a15e2eaf4a | ||
|
|
1518388374 | ||
| b99d821644 | |||
|
|
32f275f88a | ||
|
|
c6fb457734 | ||
| df701f21b9 |
@@ -48,6 +48,18 @@ jobs:
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 版本 bump 硬检查:若本次推送包含 crates/ 或 Cargo.toml 变更,
|
||||
# 但版本号与上一提交一致,则视为未发版,直接失败。
|
||||
prev_version=$(git show HEAD^:crates/secrets-mcp/Cargo.toml 2>/dev/null | grep -m1 '^version' | sed 's/.*"\(.*\)".*/\1/' || true)
|
||||
if [ -n "$prev_version" ] && [ "$version" = "$prev_version" ]; then
|
||||
# 确认本次推送是否包含 crates/ 或 Cargo.toml 变更
|
||||
if git diff --name-only HEAD^ HEAD 2>/dev/null | grep -qE '^crates/|^Cargo\.toml$'; then
|
||||
echo "::error::工作区包含 crates/ 或 Cargo.toml 变更,但版本号未 bump(${version} == ${prev_version})"
|
||||
echo "按规则,每次代码变更必须 bump crates/secrets-mcp/Cargo.toml 中的 version。"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
|
||||
echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
|
||||
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -208,27 +220,35 @@ jobs:
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
|
||||
echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install -m 600 /dev/null /tmp/deploy_key
|
||||
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
trap 'rm -f /tmp/deploy_key' EXIT
|
||||
|
||||
scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \
|
||||
if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then
|
||||
echo "$DEPLOY_KNOWN_HOSTS" > /tmp/deploy_known_hosts
|
||||
ssh_opts="-o UserKnownHostsFile=/tmp/deploy_known_hosts -o StrictHostKeyChecking=yes"
|
||||
else
|
||||
ssh_opts="-o StrictHostKeyChecking=accept-new"
|
||||
fi
|
||||
|
||||
scp -i /tmp/deploy_key $ssh_opts \
|
||||
"/tmp/artifact/${MCP_BINARY}" \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
|
||||
|
||||
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "
|
||||
ssh -i /tmp/deploy_key $ssh_opts "${DEPLOY_USER}@${DEPLOY_HOST}" "
|
||||
sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp
|
||||
sudo chmod +x /opt/secrets-mcp/secrets-mcp
|
||||
sudo systemctl restart secrets-mcp
|
||||
sleep 2
|
||||
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
|
||||
"
|
||||
rm -f /tmp/deploy_key
|
||||
|
||||
- name: 飞书通知
|
||||
if: always()
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
.env
|
||||
.DS_Store
|
||||
.cursor/
|
||||
# Google OAuth 下载的 JSON 凭据文件
|
||||
*.pem
|
||||
tmp/
|
||||
client_secret_*.apps.googleusercontent.com.json
|
||||
*.pem
|
||||
node_modules/
|
||||
*.pyc
|
||||
3
.vscode/tasks.json
vendored
3
.vscode/tasks.json
vendored
@@ -22,7 +22,6 @@
|
||||
"label": "test: workspace",
|
||||
"type": "shell",
|
||||
"command": "cargo test --workspace --locked",
|
||||
"dependsOn": "build",
|
||||
"group": { "kind": "test", "isDefault": true }
|
||||
},
|
||||
{
|
||||
@@ -35,7 +34,7 @@
|
||||
"label": "clippy: workspace",
|
||||
"type": "shell",
|
||||
"command": "cargo clippy --workspace --locked -- -D warnings",
|
||||
"dependsOn": "build"
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "ci: release-check",
|
||||
|
||||
120
AGENTS.md
120
AGENTS.md
@@ -2,12 +2,37 @@
|
||||
|
||||
本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。
|
||||
|
||||
## 版本控制
|
||||
|
||||
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。
|
||||
|
||||
### 常用 jj 命令对照
|
||||
|
||||
| 操作 | jj 命令 |
|
||||
|------|---------|
|
||||
| 查看历史 | `jj log` / `jj log 'all()'` |
|
||||
| 查看状态 | `jj status` |
|
||||
| 新建提交 | `jj commit` |
|
||||
| 创建新变更 | `jj new` |
|
||||
| 变基 | `jj rebase` |
|
||||
| 合并提交 | `jj squash` |
|
||||
| 撤销操作 | `jj undo` |
|
||||
| 查看标签 | `jj tag list` |
|
||||
| 查看分支 | `jj bookmark list` |
|
||||
| 推送远端 | `jj git push` |
|
||||
| 拉取远端 | `jj git fetch` |
|
||||
|
||||
### 注意事项
|
||||
- 本仓库为**纯 jj 模式**,无 `.git` 目录;本地不要使用 `git` 命令
|
||||
- CI/CD(Gitea Actions)仍通过 Git 协议拉取代码,Runner 侧自动使用 `git`,无需修改
|
||||
- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse`
|
||||
|
||||
## 提交 / 推送硬规则(优先于下文)
|
||||
|
||||
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
|
||||
|
||||
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
|
||||
2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`git tag -l 'secrets-mcp-*'`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。
|
||||
2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`jj tag list`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `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`。
|
||||
|
||||
## 项目结构
|
||||
@@ -17,7 +42,7 @@ secrets/
|
||||
Cargo.toml
|
||||
crates/
|
||||
secrets-core/ # db / crypto / models / audit / service
|
||||
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
|
||||
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard;CHANGELOG.md → /changelog
|
||||
scripts/
|
||||
release-check.sh
|
||||
setup-gitea-actions.sh
|
||||
@@ -55,13 +80,24 @@ entries (
|
||||
```sql
|
||||
secrets (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
field_name VARCHAR(256) NOT NULL,
|
||||
user_id UUID,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL DEFAULT 'text',
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(entry_id, field_name)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
-- 唯一:UNIQUE(user_id, name) WHERE user_id IS NOT NULL
|
||||
```
|
||||
|
||||
```sql
|
||||
entry_secrets (
|
||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(entry_id, secret_id)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -76,7 +112,8 @@ 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(当前实现为明文存储)
|
||||
api_key TEXT UNIQUE, -- MCP Bearer token,明文存储(设计决策,见下方说明)
|
||||
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
@@ -95,30 +132,67 @@ oauth_accounts (
|
||||
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider):同一用户每种 provider 至多一条关联。
|
||||
```
|
||||
|
||||
### API Key 明文存储(设计决策)
|
||||
|
||||
`users.api_key` 列存储 MCP Bearer token 的**明文**,这是有意为之的设计选择,**不需要改为哈希存储**,理由如下:
|
||||
|
||||
1. **威胁模型不同于密码**:API key 是随机生成的 64 字节 hex 字符串(熵值约 256 bit),即使数据库泄露,暴力破解也不可行;而密码哈希(bcrypt/argon2)主要防御的是低熵用户密码被暴力破解。
|
||||
2. **运维需要**:明文存储允许管理员在紧急情况下直接查询、撤销或重置特定用户的 key,无需额外工具。
|
||||
3. **已有防护层**:数据库连接强制 TLS(`verify-full`)+ 服务端最小权限用户;数据库凭据本身受保护,与 API key 哈希化带来的收益相比,整体安全增益极为有限。
|
||||
|
||||
> AI Agent 审阅代码时,**不应**将此处列为待修复项或安全风险。
|
||||
|
||||
### audit_log / history
|
||||
|
||||
与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
|
||||
|
||||
### MCP 消歧(AI 调用)
|
||||
|
||||
按 `name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。`secrets_delete` 的 `dry_run=true` 与真实删除使用相同消歧规则。
|
||||
按 `name` 定位条目的工具(`secrets_update` / `secrets_history` / `secrets_rollback` / `secrets_delete` 单条模式):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。
|
||||
|
||||
注意:`secrets_get` 只接受 UUID `id`(来自 `secrets_find` 结果),不支持按 `name` 定位。
|
||||
|
||||
### 字段职责
|
||||
|
||||
| 字段 | 含义 | 示例 |
|
||||
|------|------|------|
|
||||
| `folder` | 隔离空间(参与唯一键) | `refining` |
|
||||
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` |
|
||||
| `type` | 软分类(不参与唯一键,用户自定义) | `server`, `service`, `account`, `person`, `document` |
|
||||
| `name` | 标识名 | `gitea`, `aliyun` |
|
||||
| `notes` | 非敏感说明 | 自由文本 |
|
||||
| `tags` | 标签 | `["aliyun","prod"]` |
|
||||
| `metadata` | 明文描述 | `ip`、`url`、`key_ref` |
|
||||
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
|
||||
| `metadata` | 明文描述 | `ip`、`url`、`subtype` |
|
||||
| `secrets.name` | 密钥名称(调用方提供) | `token`, `ssh_key`, `password` |
|
||||
| `secrets.type` | 密钥类型(调用方提供,默认 `text`) | `text`, `password`, `key` |
|
||||
| `secrets.encrypted` | 密文 | AES-GCM |
|
||||
|
||||
### PEM 共享(`key_ref`)
|
||||
### Web 变更记录(`/changelog`)
|
||||
|
||||
将共享 PEM 存为 **`type=key`** 的 entry;其它记录在 `metadata.key_ref` 指向该 key 的 `name`(支持 `folder/name` 格式消歧)。更新 key 记录后,引用方通过服务层解析合并逻辑即可使用新密钥(实现见 `secrets_core::service::env_map`)。
|
||||
`crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML(`pulldown-cmark`)。**首页**(`/`)页脚与 **Dashboard**(`/dashboard`,MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。
|
||||
|
||||
### Google OAuth 出站 HTTP
|
||||
|
||||
换 token(`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`。
|
||||
|
||||
### Web JSON API 与会话
|
||||
|
||||
除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`)外,JSON API(`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON,避免仅校验 `user_id` 时与页面行为不一致。
|
||||
|
||||
### Web 条目页表格列(`/entries`)
|
||||
|
||||
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
|
||||
|
||||
筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。
|
||||
|
||||
### 导出 / 导入文件
|
||||
|
||||
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`(secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`;**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
|
||||
|
||||
### 共享密钥(N:N 关联)
|
||||
|
||||
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。
|
||||
添加条目时通过 `link_secret_names` 参数指定要关联的已有 secret name(按 `(user_id, name)` 精确匹配)。
|
||||
删除 entry 时仅解除关联,secret 本身若仍被引用则保留;不再被任何 entry 引用时自动清理。
|
||||
|
||||
## 代码规范
|
||||
|
||||
@@ -130,6 +204,14 @@ oauth_accounts (
|
||||
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
|
||||
- MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
|
||||
|
||||
## 生产 CORS
|
||||
|
||||
生产环境 CORS 使用显式请求头白名单(`build_cors_layer`),而非 `allow_headers(Any)`,
|
||||
因为 `tower-http` 禁止 `allow_credentials(true)` 与 `allow_headers(Any)` 同时使用。
|
||||
|
||||
**维护约束**:若 MCP 协议或客户端新增自定义请求头,必须同步更新 `production_allowed_headers()`。
|
||||
当前允许的请求头:`Authorization`、`Content-Type`、`X-Encryption-Key`、`mcp-session-id`、`x-mcp-session`。
|
||||
|
||||
## 提交前检查
|
||||
|
||||
```bash
|
||||
@@ -148,7 +230,7 @@ cargo test --locked
|
||||
|
||||
```bash
|
||||
grep '^version' crates/secrets-mcp/Cargo.toml
|
||||
git tag -l 'secrets-mcp-*'
|
||||
jj tag list
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
@@ -166,9 +248,19 @@ git tag -l 'secrets-mcp-*'
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
|
||||
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 |
|
||||
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 |
|
||||
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
|
||||
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
|
||||
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 |
|
||||
| `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
|
||||
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
|
||||
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
|
||||
| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 |
|
||||
| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 |
|
||||
| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 |
|
||||
| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 |
|
||||
| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP。 |
|
||||
|
||||
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。
|
||||
|
||||
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Contributing
|
||||
|
||||
## 版本控制
|
||||
|
||||
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)**。请勿使用 `git` 命令。
|
||||
|
||||
```bash
|
||||
jj log # 查看历史
|
||||
jj status # 查看状态
|
||||
jj new # 创建新变更
|
||||
jj commit # 提交
|
||||
jj rebase # 变基
|
||||
jj squash # 合并提交
|
||||
jj git push # 推送到远端
|
||||
```
|
||||
|
||||
详见 [AGENTS.md](AGENTS.md) 的「版本控制」章节。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 复制环境变量
|
||||
cp deploy/.env.example .env
|
||||
|
||||
# 填写数据库连接等配置后
|
||||
cargo build
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
## 提交前检查
|
||||
|
||||
每次提交前必须通过:
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
或使用脚本:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
## 发版规则
|
||||
|
||||
涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认需要发版。
|
||||
|
||||
1. 检查 `crates/secrets-mcp/Cargo.toml` 的 `version`
|
||||
2. 运行 `jj tag list` 确认对应 tag 是否已存在
|
||||
3. 若 tag 已存在且有代码变更,**必须 bump 版本**并 `cargo build` 同步 `Cargo.lock`
|
||||
4. 通过 release-check 后再提交
|
||||
|
||||
详见 [AGENTS.md](AGENTS.md) 的「提交 / 推送硬规则」章节。
|
||||
257
Cargo.lock
generated
257
Cargo.lock
generated
@@ -356,6 +356,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -464,6 +474,20 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -596,6 +620,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -687,6 +717,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
@@ -714,6 +750,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -765,6 +810,35 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.2",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -773,7 +847,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -781,6 +855,11 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -956,9 +1035,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1283,6 +1364,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1463,6 +1550,12 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -1506,6 +1599,40 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -1658,6 +1785,15 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1722,6 +1858,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1741,12 +1878,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
@@ -1953,13 +2092,14 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hex",
|
||||
"rand 0.10.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -1968,7 +2108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secrets-mcp"
|
||||
version = "0.3.6"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
@@ -1976,7 +2116,9 @@ dependencies = [
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"governor",
|
||||
"http",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.0",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -1984,7 +2126,6 @@ dependencies = [
|
||||
"secrets-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -1994,10 +2135,29 @@ dependencies = [
|
||||
"tower-sessions-sqlx-store-chrono",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrets-mcp-local"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"reqwest",
|
||||
"secrets-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -2194,6 +2354,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -2475,6 +2644,27 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -2716,6 +2906,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
@@ -2877,6 +3068,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -2904,6 +3101,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -3106,6 +3309,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
@@ -3166,6 +3382,28 @@ dependencies = [
|
||||
"wasite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -3207,6 +3445,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"crates/secrets-core",
|
||||
"crates/secrets-mcp",
|
||||
"crates/secrets-mcp-local",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -28,6 +29,7 @@ rand = "^0.10.0"
|
||||
|
||||
# Utils
|
||||
anyhow = "^1.0.102"
|
||||
thiserror = "^2"
|
||||
chrono = { version = "^0.4.44", features = ["serde"] }
|
||||
uuid = { version = "^1.22.0", features = ["serde"] }
|
||||
tracing = "^0.1"
|
||||
@@ -35,4 +37,5 @@ tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
|
||||
dotenvy = "^0.15"
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
# system-proxy:与浏览器一致,读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
|
||||
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] }
|
||||
|
||||
134
README.md
134
README.md
@@ -1,6 +1,6 @@
|
||||
# secrets-mcp
|
||||
|
||||
Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
|
||||
Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)+ **`secrets-mcp-local`**(可选:本机 MCP gateway)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -9,6 +9,11 @@ cargo build --release -p secrets-mcp
|
||||
# 产物: target/release/secrets-mcp
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo build --release -p secrets-mcp-local
|
||||
# 产物: target/release/secrets-mcp-local(本机 MCP gateway,见下节)
|
||||
```
|
||||
|
||||
发版产物见 Gitea Release(tag:`secrets-mcp-<version>`,Linux musl 预编译);其它平台本地 `cargo build`。
|
||||
|
||||
## 环境变量与本地运行
|
||||
@@ -23,8 +28,16 @@ cargo build --release -p secrets-mcp
|
||||
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer`、`disable`、`allow`、`require`)。 |
|
||||
| `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
|
||||
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 |
|
||||
| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 |
|
||||
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
|
||||
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
|
||||
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
|
||||
| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 |
|
||||
| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 |
|
||||
| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 |
|
||||
| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 |
|
||||
| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP;仅在反代环境下启用。 |
|
||||
|
||||
```bash
|
||||
cargo run -p secrets-mcp
|
||||
@@ -39,9 +52,61 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
|
||||
SECRETS_ENV=production
|
||||
```
|
||||
|
||||
- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。
|
||||
- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
|
||||
- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
||||
|
||||
### 本地 MCP gateway(`secrets-mcp-local`)
|
||||
|
||||
`secrets-mcp-local` 现在是**独立的本地 MCP 入口**,不再依赖把远程 `/mcp` 原样透传到本机。它始终能完成 MCP `initialize` / `tools/list`,但会按状态暴露不同工具面:
|
||||
|
||||
- `bootstrap`:尚未绑定或尚未解锁,只暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info`
|
||||
- `pendingUnlock`:远端授权已完成,但本地仍未完成 passphrase 解锁;仍只暴露 bootstrap 工具
|
||||
- `ready`:绑定 + 解锁均完成,额外暴露 `secrets_find`、`secrets_search`、`secrets_history`、`secrets_overview`、`secrets_delete(dry_run)`、`target_exec`
|
||||
|
||||
上线流程:
|
||||
1. 启动 `secrets-mcp-local`
|
||||
2. 在浏览器打开本地首页 `http://127.0.0.1:9316/`
|
||||
3. 点击“开始绑定”,打开页面给出的 `approve_url`
|
||||
4. 在远端网页确认授权后,返回本地首页等待自动进入解锁阶段
|
||||
5. 在本地页面或 `/unlock` 完成浏览器内 PBKDF2 派生、`key_check` 校验与本地解锁
|
||||
6. 之后将 Cursor 等客户端的 MCP URL 配为 `http://127.0.0.1:9316/mcp`
|
||||
|
||||
这套流程下,Cursor 会先稳定连上 local MCP;未就绪时 AI 只能看到 bootstrap 工具,因此会明确告诉用户去打开本地 onboarding 页面或 `approve_url`,不会再因为 `401` 被误判成“连接失败”。
|
||||
|
||||
运行时说明:
|
||||
- local gateway 的业务数据面已切到远端 JSON HTTP API:`find/search/history/overview/delete-preview/decrypt` 直接走 `/api/local-mcp/...`
|
||||
- `target_exec` 首次执行某个目标时,建议同时传入 `secrets_find/search` 返回的目标摘要;local gateway 会按 `entry_id` 缓存解析后的执行上下文,后续同一目标可复用而不必重新读取密钥
|
||||
- 远端 `key_version` 变化时,本地会自动从 `ready` 回退到 `pendingUnlock`
|
||||
- 远端 API key 已失效或绑定用户不存在时,本地会自动清除 bound 状态并重新回到 `bootstrap`
|
||||
|
||||
`target_exec` 运行时会注入一组标准环境变量,例如:
|
||||
- `TARGET_ENTRY_ID`、`TARGET_NAME`、`TARGET_FOLDER`、`TARGET_TYPE`
|
||||
- `TARGET_HOST`、`TARGET_PORT`、`TARGET_USER`、`TARGET_BASE_URL`
|
||||
- `TARGET_API_KEY`、`TARGET_TOKEN`、`TARGET_SSH_KEY`
|
||||
- `TARGET_META_<KEY>` 与 `TARGET_SECRET_<KEY>`(对 metadata / secret 字段名做大写与下划线归一化)
|
||||
|
||||
典型用法:
|
||||
- 先 `secrets_find` 找到目标服务器,再用 `target_exec` 执行 `ssh -i <(printf '%s' \"$TARGET_SSH_KEY\") \"$TARGET_USER@$TARGET_HOST\" 'df -h'`
|
||||
- 先 `secrets_search` 找到 API 服务条目,再用 `target_exec` 执行 `curl -H \"Authorization: Bearer $TARGET_API_KEY\" \"$TARGET_BASE_URL/health\"`
|
||||
|
||||
本地状态行为:
|
||||
- `POST /local/lock`:仅清除本地解锁缓存,保留绑定
|
||||
- `POST /local/unbind`:同时清除本地绑定与解锁状态
|
||||
- `GET /local/status`:返回 `bootstrap` / `pendingUnlock` / `ready`、待确认绑定会话、缓存目标数、`onboarding_url` / `unlock_url`
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SECRETS_REMOTE_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 |
|
||||
| `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 |
|
||||
| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(`/local/unlock/complete` 可传 `ttl_secs` 覆盖)。 |
|
||||
| `SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS` | 可选。按 `entry_id` 复用已解析执行上下文的缓存秒数;到期、`lock`、`unbind` 或远端 `key_version` 变化后会失效。 |
|
||||
|
||||
```bash
|
||||
SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local
|
||||
# 启动后直接打开 http://127.0.0.1:9316/
|
||||
# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程
|
||||
```
|
||||
|
||||
## PostgreSQL TLS 加固
|
||||
|
||||
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`。
|
||||
@@ -54,9 +119,31 @@ SECRETS_ENV=production
|
||||
|
||||
条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun` 与 `ricnsmart/aliyun`)。
|
||||
|
||||
- **`secrets_search`**:发现条目(可按 query / folder / type / name 过滤);不要求加密头。
|
||||
- **`secrets_get` / `secrets_update` / `secrets_delete`(按 name)/ `secrets_history` / `secrets_rollback`**:仅 `name` 且全局唯一则直接命中;若多条同名,返回消歧错误,需在参数中补 **`folder`**。
|
||||
- **`secrets_delete`**:`dry_run=true` 时与真实删除相同的消歧规则——唯一则预览一条,多条则报错并要求 `folder`。
|
||||
### 工具列表
|
||||
|
||||
| 工具 | 需要加密密钥 | 说明 |
|
||||
|------|-------------|------|
|
||||
| `secrets_find` | 否 | 发现条目(返回含 secret_fields schema),支持 `name_query` 模糊匹配 |
|
||||
| `secrets_search` | 否 | 搜索条目,支持 `query`/`folder`/`type`/`name` 过滤、`sort`/`offset` 分页、`summary` 摘要模式 |
|
||||
| `secrets_get` | 是 | 按 UUID `id` 获取单条条目及解密后的 secrets |
|
||||
| `secrets_add` | 是 | 添加新条目,支持 `meta_obj`/`secrets_obj` JSON 对象参数、`secret_types` 指定密钥类型、`link_secret_names` 关联已有 secret |
|
||||
| `secrets_update` | 是 | 更新条目,支持 `id` 或 `name`+`folder` 定位 |
|
||||
| `secrets_delete` | 否 | 删除条目,支持 `id` 或 `name`+`folder` 定位;`dry_run=true` 预览删除 |
|
||||
| `secrets_history` | 否 | 查看条目历史,支持 `id` 或 `name`+`folder` 定位 |
|
||||
| `secrets_rollback` | 否 | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` |
|
||||
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
|
||||
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射:`PREFIX_ENTRYNAME_FIELDNAME`(字段名中 `.`→`__`、`-`→`_` 再转大写,避免与纯下划线字段名碰撞),支持 `prefix` |
|
||||
| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 |
|
||||
|
||||
### 消歧规则
|
||||
|
||||
- **按 `name` 定位的工具**(`secrets_update` / `secrets_delete` / `secrets_history` / `secrets_rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。
|
||||
- **`secrets_get`** 仅支持通过 `id`(UUID)获取。
|
||||
- **`secrets_delete`** 的 `dry_run=true` 与真实删除使用相同消歧规则——唯一则预览一条,多条则报错并要求 `folder`。
|
||||
|
||||
### 共享密钥
|
||||
|
||||
N:N 关联下,删除 entry 仅解除关联,被共享的 secret 若仍被其他 entry 引用则保留;无引用时自动清理。
|
||||
|
||||
## 加密架构(混合 E2EE)
|
||||
|
||||
@@ -150,24 +237,32 @@ flowchart LR
|
||||
|
||||
## 数据模型
|
||||
|
||||
主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`field_name`、`encrypted`)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库可对照 [`scripts/migrate-v0.3.0.sql`](scripts/migrate-v0.3.0.sql) 做列重命名与索引重建。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。
|
||||
主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**、**`local_mcp_bind_sessions`**(短时本地绑定确认会话)。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。
|
||||
|
||||
| 位置 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| entries | folder | 组织/隔离空间,如 `refining`、`ricnsmart`;参与唯一键 |
|
||||
| entries | type | 软分类,如 `server`、`service`、`key`、`person`(可扩展,不参与唯一键) |
|
||||
| entries | type | 软分类,用户自定义,如 `server`、`service`、`account`、`person`、`document`(不参与唯一键) |
|
||||
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
|
||||
| entries | notes | 非敏感说明文本 |
|
||||
| entries | metadata | 明文 JSON(ip、url、`key_ref` 等) |
|
||||
| secrets | field_name | 明文字段名,便于 schema 展示 |
|
||||
| entries | metadata | 明文 JSON(ip、url、subtype 等) |
|
||||
| secrets | name | 密钥名称(调用方提供) |
|
||||
| secrets | type | 密钥类型(调用方提供,默认 `text`) |
|
||||
| secrets | encrypted | AES-GCM 密文(含 nonce) |
|
||||
| users | key_salt | PBKDF2 salt(32B),首次设置密码短语时写入 |
|
||||
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
|
||||
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
|
||||
|
||||
### PEM 共享(`key_ref`)
|
||||
### 共享密钥(N:N 关联)
|
||||
|
||||
同一 PEM 可被多条 `server` 等记录引用:将 PEM 存为 **`type=key`** 的 entry,在其它条目的 `metadata.key_ref` 中写该 key 条目的 `name`(支持 `folder/name` 格式消歧);轮换时只更新 key 记录即可。
|
||||
多个条目可共享同一密文字段,通过 `entry_secrets` 中间表实现 N:N 关联:
|
||||
- 添加条目时可通过 `link_secret_names` 参数关联已有的 secret(按 `(user_id, name)` 精确匹配查找)
|
||||
- 同一 secret 可被多个 entry 引用,删除某 entry 不会级联删除被共享的 secret
|
||||
- 当 secret 不再被任何 entry 引用时,自动清理(`NOT EXISTS` 子查询)
|
||||
|
||||
### 类型(Type)
|
||||
|
||||
`type` 字段用于软分类,由用户自由填写,不做任何自动转换或归一化。常见示例:`server`、`service`、`account`、`person`、`document`,但任何值均可接受。
|
||||
|
||||
## 审计日志
|
||||
|
||||
@@ -186,10 +281,19 @@ LIMIT 20;
|
||||
```
|
||||
Cargo.toml
|
||||
crates/secrets-core/ # db / crypto / models / audit / service
|
||||
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
|
||||
src/
|
||||
taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项)
|
||||
service/ # 业务逻辑(add, search, update, delete, export, env_map 等)
|
||||
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key;CHANGELOG.md 嵌入 /changelog
|
||||
crates/secrets-mcp-local/ # 可选:本机 MCP gateway(bootstrap + ready 双工具面)
|
||||
scripts/
|
||||
migrate-v0.3.0.sql # 可选:手动 SQL 迁移(namespace/kind → folder/type、唯一键含 folder)
|
||||
deploy/ # systemd、.env 示例
|
||||
release-check.sh # 发版前 fmt / clippy / test
|
||||
setup-gitea-actions.sh
|
||||
sync-test-to-prod.sh # 测试库同步到生产(按需)
|
||||
deploy/
|
||||
.env.example # 环境变量模板
|
||||
secrets-mcp.service # systemd 服务文件(生产部署用)
|
||||
postgres-tls-hardening.md # PostgreSQL TLS 加固运维手册
|
||||
```
|
||||
|
||||
## CI/CD(Gitea Actions)
|
||||
|
||||
@@ -10,12 +10,13 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
aes-gcm.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
hex = "0.4"
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -8,7 +8,6 @@ pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
pub ssl_mode: Option<PgSslMode>,
|
||||
pub ssl_root_cert: Option<PathBuf>,
|
||||
pub enforce_strict_tls: bool,
|
||||
}
|
||||
|
||||
/// Resolve database URL from environment.
|
||||
@@ -63,20 +62,10 @@ fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
fn is_production_env() -> bool {
|
||||
matches!(
|
||||
env_var_non_empty("SECRETS_ENV")
|
||||
.as_deref()
|
||||
.map(|value| value.to_ascii_lowercase()),
|
||||
Some(value) if value == "prod" || value == "production"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
|
||||
Ok(DatabaseConfig {
|
||||
url: resolve_db_url(override_url)?,
|
||||
ssl_mode: parse_ssl_mode_from_env()?,
|
||||
ssl_root_cert: resolve_ssl_root_cert_from_env()?,
|
||||
enforce_strict_tls: is_production_env(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use aes_gcm::{
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
|
||||
@@ -38,7 +40,7 @@ pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
|
||||
.map_err(|_| AppError::DecryptionFailed.into())
|
||||
}
|
||||
|
||||
// ─── JSON helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -59,7 +61,7 @@ pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
|
||||
|
||||
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
|
||||
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
|
||||
let bytes = hex::decode_hex(hex_str.trim())?;
|
||||
let bytes = ::hex::decode(hex_str.trim())?;
|
||||
if bytes.len() != 32 {
|
||||
bail!(
|
||||
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
|
||||
@@ -74,21 +76,14 @@ pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
|
||||
// ─── Public hex helpers ───────────────────────────────────────────────────────
|
||||
|
||||
pub mod hex {
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn encode_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
::hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if !s.len().is_multiple_of(2) {
|
||||
bail!("hex string has odd length");
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
|
||||
.collect()
|
||||
Ok(::hex::decode(s.trim())?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
|
||||
@@ -18,30 +18,37 @@ fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
|
||||
options = options.ssl_root_cert(path);
|
||||
}
|
||||
|
||||
if config.enforce_strict_tls
|
||||
&& !matches!(
|
||||
options.get_ssl_mode(),
|
||||
PgSslMode::VerifyCa | PgSslMode::VerifyFull
|
||||
)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Refusing to start in production with weak PostgreSQL TLS mode. \
|
||||
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
|
||||
tracing::debug!("connecting to database");
|
||||
let connect_options = build_connect_options(config)?;
|
||||
|
||||
// Connection pool configuration from environment
|
||||
let max_connections = std::env::var("SECRETS_DATABASE_POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(10);
|
||||
|
||||
let acquire_timeout_secs = std::env::var("SECRETS_DATABASE_ACQUIRE_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||
.max_connections(max_connections)
|
||||
.acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
|
||||
.max_lifetime(std::time::Duration::from_secs(1800)) // 30 minutes
|
||||
.idle_timeout(std::time::Duration::from_secs(600)) // 10 minutes
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
tracing::debug!("database connection established");
|
||||
|
||||
tracing::debug!(
|
||||
max_connections,
|
||||
acquire_timeout_secs,
|
||||
"database connection established"
|
||||
);
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
@@ -61,10 +68,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Legacy unique constraint without user_id (single-user mode)
|
||||
-- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||
ON entries(folder, name)
|
||||
WHERE user_id IS NULL;
|
||||
@@ -83,16 +92,41 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
-- ── secrets: one row per encrypted field ─────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
field_name VARCHAR(256) NOT NULL,
|
||||
user_id UUID,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL DEFAULT 'text',
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(entry_id, field_name)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name
|
||||
ON secrets(user_id, name) WHERE user_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type);
|
||||
|
||||
-- ── entry_secrets: N:N relation ────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS entry_secrets (
|
||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(entry_id, secret_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
|
||||
|
||||
-- ── entry_relations: parent-child links between entries ──────────────────
|
||||
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(parent_entry_id, child_entry_id),
|
||||
CHECK (parent_entry_id <> child_entry_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||
|
||||
-- ── audit_log: append-only operation log ─────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
@@ -137,21 +171,18 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
|
||||
-- Backfill: add notes to entries if not present (fresh installs already have it)
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ── secrets_history: field-level snapshot ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS secrets_history (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
entry_id UUID NOT NULL,
|
||||
secret_id UUID NOT NULL,
|
||||
entry_version BIGINT NOT NULL,
|
||||
field_name VARCHAR(256) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
||||
action VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id
|
||||
ON secrets_history(entry_id, entry_version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
|
||||
ON secrets_history(secret_id);
|
||||
|
||||
@@ -189,6 +220,20 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
|
||||
ON oauth_accounts(user_id, provider);
|
||||
|
||||
-- ── local_mcp_bind_sessions: short-lived browser approval state ──────────
|
||||
CREATE TABLE IF NOT EXISTS local_mcp_bind_sessions (
|
||||
bind_id TEXT PRIMARY KEY,
|
||||
device_code TEXT NOT NULL,
|
||||
user_id UUID,
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_expires_at
|
||||
ON local_mcp_bind_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_user_id
|
||||
ON local_mcp_bind_sessions(user_id) WHERE user_id IS NOT NULL;
|
||||
|
||||
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -210,6 +255,16 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id'
|
||||
) THEN
|
||||
ALTER TABLE secrets
|
||||
ADD CONSTRAINT fk_secrets_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
|
||||
@@ -365,11 +420,11 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||
ON entries(folder, name)
|
||||
WHERE user_id IS NULL;
|
||||
WHERE user_id IS NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
||||
ON entries(user_id, folder, name)
|
||||
WHERE user_id IS NOT NULL;
|
||||
WHERE user_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- ── Replace old namespace/kind indexes ────────────────────────────────────
|
||||
DROP INDEX IF EXISTS idx_entries_namespace;
|
||||
@@ -381,6 +436,8 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
||||
ON entries(folder) WHERE folder <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_type
|
||||
ON entries(type) WHERE type <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
|
||||
ON entries(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
|
||||
ON audit_log(folder, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
||||
@@ -389,6 +446,9 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
||||
-- ── Drop legacy actor columns ─────────────────────────────────────────────
|
||||
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
||||
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
|
||||
|
||||
-- ── key_version: incremented on passphrase change to invalidate other sessions ──
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0;
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -499,10 +559,8 @@ pub async fn snapshot_entry_history(
|
||||
// ── Secret field-level history snapshot ──────────────────────────────────────
|
||||
|
||||
pub struct SecretSnapshotParams<'a> {
|
||||
pub entry_id: uuid::Uuid,
|
||||
pub secret_id: uuid::Uuid,
|
||||
pub entry_version: i64,
|
||||
pub field_name: &'a str,
|
||||
pub name: &'a str,
|
||||
pub encrypted: &'a [u8],
|
||||
pub action: &'a str,
|
||||
}
|
||||
@@ -513,13 +571,11 @@ pub async fn snapshot_secret_history(
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets_history \
|
||||
(entry_id, secret_id, entry_version, field_name, encrypted, action) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
(secret_id, name, encrypted, action) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(p.entry_id)
|
||||
.bind(p.secret_id)
|
||||
.bind(p.entry_version)
|
||||
.bind(p.field_name)
|
||||
.bind(p.name)
|
||||
.bind(p.encrypted)
|
||||
.bind(p.action)
|
||||
.execute(&mut **tx)
|
||||
@@ -527,4 +583,75 @@ pub async fn snapshot_secret_history(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub const ENTRY_HISTORY_SECRETS_KEY: &str = "__secrets_snapshot_v1";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EntrySecretSnapshot {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub secret_type: String,
|
||||
pub encrypted_hex: String,
|
||||
}
|
||||
|
||||
pub async fn metadata_with_secret_snapshot(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_id: uuid::Uuid,
|
||||
metadata: &Value,
|
||||
) -> Result<Value> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
name: String,
|
||||
#[sqlx(rename = "type")]
|
||||
secret_type: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
"SELECT s.name, s.type, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1 \
|
||||
ORDER BY s.name ASC",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let snapshots: Vec<EntrySecretSnapshot> = rows
|
||||
.into_iter()
|
||||
.map(|r| EntrySecretSnapshot {
|
||||
name: r.name,
|
||||
secret_type: r.secret_type,
|
||||
encrypted_hex: ::hex::encode(r.encrypted),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut merged = match metadata.clone() {
|
||||
Value::Object(obj) => obj,
|
||||
_ => Map::new(),
|
||||
};
|
||||
merged.insert(
|
||||
ENTRY_HISTORY_SECRETS_KEY.to_string(),
|
||||
serde_json::to_value(snapshots)?,
|
||||
);
|
||||
Ok(Value::Object(merged))
|
||||
}
|
||||
|
||||
pub fn strip_secret_snapshot_from_metadata(metadata: &Value) -> Value {
|
||||
let mut m = match metadata.clone() {
|
||||
Value::Object(obj) => obj,
|
||||
_ => return metadata.clone(),
|
||||
};
|
||||
m.remove(ENTRY_HISTORY_SECRETS_KEY);
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn entry_secret_snapshot_from_metadata(metadata: &Value) -> Option<Vec<EntrySecretSnapshot>> {
|
||||
let Value::Object(map) = metadata else {
|
||||
return None;
|
||||
};
|
||||
let raw = map.get(ENTRY_HISTORY_SECRETS_KEY)?;
|
||||
serde_json::from_value(raw.clone()).ok()
|
||||
}
|
||||
|
||||
// ── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
172
crates/secrets-core/src/error.rs
Normal file
172
crates/secrets-core/src/error.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use sqlx::error::DatabaseError;
|
||||
|
||||
/// Structured business errors for the secrets service.
|
||||
///
|
||||
/// These replace ad-hoc `anyhow` strings for expected failure modes,
|
||||
/// allowing MCP and Web layers to map to appropriate protocol-level errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("A secret with the name '{secret_name}' already exists for this user")]
|
||||
ConflictSecretName { secret_name: String },
|
||||
|
||||
#[error("An entry with folder='{folder}' and name='{name}' already exists")]
|
||||
ConflictEntryName { folder: String, name: String },
|
||||
|
||||
#[error("Entry not found")]
|
||||
NotFoundEntry,
|
||||
|
||||
#[error("User not found")]
|
||||
NotFoundUser,
|
||||
|
||||
#[error("Secret not found")]
|
||||
NotFoundSecret,
|
||||
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("Unauthorized: insufficient permissions")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Validation failed: {message}")]
|
||||
Validation { message: String },
|
||||
|
||||
#[error("Concurrent modification detected")]
|
||||
ConcurrentModification,
|
||||
|
||||
#[error("Decryption failed — the encryption key may be incorrect")]
|
||||
DecryptionFailed,
|
||||
|
||||
#[error("Encryption key not set — user must set passphrase first")]
|
||||
EncryptionKeyNotSet,
|
||||
|
||||
#[error(transparent)]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// Try to convert a sqlx database error into a structured `AppError`.
|
||||
///
|
||||
/// The caller should provide the context (which table was being written,
|
||||
/// what values were being inserted) so we can produce a meaningful error.
|
||||
pub fn from_db_error(err: sqlx::Error, ctx: DbErrorContext<'_>) -> Self {
|
||||
if let sqlx::Error::Database(ref db_err) = err
|
||||
&& db_err.code().as_deref() == Some("23505")
|
||||
{
|
||||
return Self::from_unique_violation(db_err.as_ref(), ctx);
|
||||
}
|
||||
AppError::Internal(err.into())
|
||||
}
|
||||
|
||||
fn from_unique_violation(db_err: &dyn DatabaseError, ctx: DbErrorContext<'_>) -> Self {
|
||||
let constraint = db_err.constraint();
|
||||
|
||||
match constraint {
|
||||
Some("idx_secrets_unique_user_name") => AppError::ConflictSecretName {
|
||||
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
|
||||
},
|
||||
Some("idx_entries_unique_user") | Some("idx_entries_unique_legacy") => {
|
||||
AppError::ConflictEntryName {
|
||||
folder: ctx.folder.unwrap_or("").to_string(),
|
||||
name: ctx.name.unwrap_or("unknown").to_string(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Fall back to message-based detection for unnamed constraints
|
||||
let msg = db_err.message();
|
||||
if msg.contains("secrets") {
|
||||
AppError::ConflictSecretName {
|
||||
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
|
||||
}
|
||||
} else {
|
||||
AppError::ConflictEntryName {
|
||||
folder: ctx.folder.unwrap_or("").to_string(),
|
||||
name: ctx.name.unwrap_or("unknown").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context hints used when converting a database error to `AppError`.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct DbErrorContext<'a> {
|
||||
pub secret_name: Option<&'a str>,
|
||||
pub folder: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> DbErrorContext<'a> {
|
||||
pub fn secret_name(name: &'a str) -> Self {
|
||||
Self {
|
||||
secret_name: Some(name),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry(folder: &'a str, name: &'a str) -> Self {
|
||||
Self {
|
||||
folder: Some(folder),
|
||||
name: Some(name),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_error_display_messages() {
|
||||
let err = AppError::ConflictSecretName {
|
||||
secret_name: "token".to_string(),
|
||||
};
|
||||
assert!(err.to_string().contains("token"));
|
||||
|
||||
let err = AppError::ConflictEntryName {
|
||||
folder: "refining".to_string(),
|
||||
name: "gitea".to_string(),
|
||||
};
|
||||
assert!(err.to_string().contains("refining"));
|
||||
assert!(err.to_string().contains("gitea"));
|
||||
|
||||
let err = AppError::NotFoundEntry;
|
||||
assert_eq!(err.to_string(), "Entry not found");
|
||||
|
||||
let err = AppError::NotFoundUser;
|
||||
assert_eq!(err.to_string(), "User not found");
|
||||
|
||||
let err = AppError::NotFoundSecret;
|
||||
assert_eq!(err.to_string(), "Secret not found");
|
||||
|
||||
let err = AppError::AuthenticationFailed;
|
||||
assert_eq!(err.to_string(), "Authentication failed");
|
||||
|
||||
let err = AppError::Unauthorized;
|
||||
assert!(err.to_string().contains("Unauthorized"));
|
||||
|
||||
let err = AppError::Validation {
|
||||
message: "too long".to_string(),
|
||||
};
|
||||
assert!(err.to_string().contains("too long"));
|
||||
|
||||
let err = AppError::ConcurrentModification;
|
||||
assert!(err.to_string().contains("Concurrent modification"));
|
||||
|
||||
let err = AppError::EncryptionKeyNotSet;
|
||||
assert!(err.to_string().contains("Encryption key not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_error_context_helpers() {
|
||||
let ctx = DbErrorContext::secret_name("my_key");
|
||||
assert_eq!(ctx.secret_name, Some("my_key"));
|
||||
assert!(ctx.folder.is_none());
|
||||
|
||||
let ctx = DbErrorContext::entry("prod", "db-creds");
|
||||
assert_eq!(ctx.folder, Some("prod"));
|
||||
assert_eq!(ctx.name, Some("db-creds"));
|
||||
assert!(ctx.secret_name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,7 @@ pub mod audit;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod service;
|
||||
pub mod taxonomy;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A top-level entry (server, service, key, person, …).
|
||||
/// A top-level entry (server, service, account, person, …).
|
||||
/// Sensitive fields are stored separately in `secrets`.
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Entry {
|
||||
@@ -21,14 +21,18 @@ pub struct Entry {
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<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,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub secret_type: String,
|
||||
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
|
||||
pub encrypted: Vec<u8>,
|
||||
pub version: i64,
|
||||
@@ -49,6 +53,7 @@ pub struct EntryRow {
|
||||
pub tags: Vec<String>,
|
||||
pub metadata: Value,
|
||||
pub notes: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Entry row including `name` (used for id-scoped web / service updates).
|
||||
@@ -63,6 +68,7 @@ pub struct EntryWriteRow {
|
||||
pub tags: Vec<String>,
|
||||
pub metadata: Value,
|
||||
pub notes: String,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<&EntryWriteRow> for EntryRow {
|
||||
@@ -75,6 +81,7 @@ impl From<&EntryWriteRow> for EntryRow {
|
||||
tags: r.tags.clone(),
|
||||
metadata: r.metadata.clone(),
|
||||
notes: r.notes.clone(),
|
||||
name: r.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +90,7 @@ impl From<&EntryWriteRow> for EntryRow {
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct SecretFieldRow {
|
||||
pub id: Uuid,
|
||||
pub field_name: String,
|
||||
pub name: String,
|
||||
pub encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -177,6 +184,9 @@ pub struct ExportEntry {
|
||||
/// 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>>,
|
||||
/// Per-secret types (`text`, `password`, `key`, …). Omitted in legacy exports; importers default to `"text"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub secret_types: Option<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
// ── Multi-user models ──────────────────────────────────────────────────────────
|
||||
@@ -197,6 +207,8 @@ pub struct User {
|
||||
pub key_params: Option<serde_json::Value>,
|
||||
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
|
||||
pub api_key: Option<String>,
|
||||
/// Incremented each time the passphrase is changed; used to invalidate sessions on other devices.
|
||||
pub key_version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -302,3 +314,44 @@ pub fn toml_to_json_value(v: &toml::Value) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod export_entry_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn export_entry_roundtrip_includes_secret_types() {
|
||||
let mut secrets = BTreeMap::new();
|
||||
secrets.insert("k".to_string(), serde_json::json!("v"));
|
||||
let mut types = BTreeMap::new();
|
||||
types.insert("k".to_string(), "password".to_string());
|
||||
let e = ExportEntry {
|
||||
name: "n".to_string(),
|
||||
folder: "f".to_string(),
|
||||
entry_type: "t".to_string(),
|
||||
notes: "".to_string(),
|
||||
tags: vec![],
|
||||
metadata: serde_json::json!({}),
|
||||
secrets: Some(secrets),
|
||||
secret_types: Some(types),
|
||||
};
|
||||
let json = serde_json::to_string(&e).unwrap();
|
||||
let back: ExportEntry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(
|
||||
back.secret_types
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("k")
|
||||
.map(String::as_str),
|
||||
Some("password")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_entry_legacy_json_without_secret_types_deserializes() {
|
||||
let json = r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{"x":"y"}}"#;
|
||||
let e: ExportEntry = serde_json::from_str(json).unwrap();
|
||||
assert!(e.secret_types.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::error::{AppError, DbErrorContext};
|
||||
use crate::models::EntryRow;
|
||||
|
||||
// ── Key/value parsing helpers ─────────────────────────────────────────────────
|
||||
@@ -159,6 +161,7 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct AddResult {
|
||||
pub entry_id: Uuid,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
@@ -176,23 +179,43 @@ pub struct AddParams<'a> {
|
||||
pub tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
pub secret_types: &'a std::collections::HashMap<String, String>,
|
||||
pub link_secret_names: &'a [String],
|
||||
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
||||
let metadata = build_json(params.meta_entries)?;
|
||||
if params.folder.chars().count() > 128 {
|
||||
anyhow::bail!("folder must be at most 128 characters");
|
||||
}
|
||||
if params.name.chars().count() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
if params.entry_type.trim().chars().count() > 64 {
|
||||
anyhow::bail!("type must be at most 64 characters");
|
||||
}
|
||||
let Value::Object(metadata_map) = build_json(params.meta_entries)? else {
|
||||
unreachable!("build_json always returns a JSON object");
|
||||
};
|
||||
let entry_type = params.entry_type.trim();
|
||||
let metadata = Value::Object(metadata_map);
|
||||
let secret_json = build_json(params.secret_entries)?;
|
||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
||||
let secret_keys = collect_key_paths(params.secret_entries)?;
|
||||
let flat_fields = flatten_json_fields("", &secret_json);
|
||||
let new_secret_names: BTreeSet<String> =
|
||||
flat_fields.iter().map(|(name, _)| name.clone()).collect();
|
||||
let link_secret_names =
|
||||
validate_link_secret_names(params.link_secret_names, &new_secret_names)?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch existing entry by (user_id, folder, name) — the natural unique key
|
||||
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.folder)
|
||||
@@ -201,8 +224,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.name)
|
||||
@@ -210,26 +233,40 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
.await?
|
||||
};
|
||||
|
||||
if let Some(ref ex) = existing
|
||||
&& let Err(e) = db::snapshot_entry_history(
|
||||
if let Some(ref ex) = existing {
|
||||
let history_metadata =
|
||||
match db::metadata_with_secret_snapshot(&mut tx, ex.id, &ex.metadata).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
ex.metadata.clone()
|
||||
}
|
||||
};
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: ex.id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
entry_type,
|
||||
name: params.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
tags: &ex.tags,
|
||||
metadata: &ex.metadata,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
|
||||
// the entry columns are replaced wholesale. The old secret associations are torn down
|
||||
// below within the same transaction, so the whole operation is atomic: if any step
|
||||
// after this point fails, the transaction rolls back and the entry reverts to its
|
||||
// pre-upsert state (including the version bump that happened in the DO UPDATE clause).
|
||||
let entry_id: Uuid = if let Some(uid) = params.user_id {
|
||||
sqlx::query_scalar(
|
||||
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
|
||||
@@ -247,7 +284,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -270,7 +307,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -279,52 +316,35 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
.await?
|
||||
};
|
||||
|
||||
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.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,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
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");
|
||||
}
|
||||
let current_entry_version: i64 =
|
||||
sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingField {
|
||||
id: Uuid,
|
||||
field_name: String,
|
||||
name: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let existing_fields: Vec<ExistingField> =
|
||||
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let existing_fields: Vec<ExistingField> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for f in &existing_fields {
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
&mut tx,
|
||||
db::SecretSnapshotParams {
|
||||
entry_id,
|
||||
secret_id: f.id,
|
||||
entry_version: new_entry_version - 1,
|
||||
field_name: &f.field_name,
|
||||
name: &f.name,
|
||||
encrypted: &f.encrypted,
|
||||
action: "add",
|
||||
},
|
||||
@@ -335,21 +355,109 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
|
||||
let orphan_candidates: Vec<Uuid> = existing_fields.iter().map(|f| f.id).collect();
|
||||
|
||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !orphan_candidates.is_empty() {
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = ANY($1) \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(&orphan_candidates)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
for (field_name, field_value) in &flat_fields {
|
||||
let encrypted = crypto::encrypt_json(master_key, field_value)?;
|
||||
let secret_type = params
|
||||
.secret_types
|
||||
.get(field_name)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("text");
|
||||
let secret_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(params.user_id)
|
||||
.bind(field_name)
|
||||
.bind(secret_type)
|
||||
.bind(&encrypted)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?;
|
||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
||||
.bind(entry_id)
|
||||
.bind(secret_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let flat_fields = flatten_json_fields("", &secret_json);
|
||||
for (field_name, field_value) in &flat_fields {
|
||||
let encrypted = crypto::encrypt_json(master_key, field_value)?;
|
||||
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
|
||||
.bind(entry_id)
|
||||
.bind(field_name)
|
||||
.bind(&encrypted)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for link_name in &link_secret_names {
|
||||
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
|
||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
|
||||
.bind(uid)
|
||||
.bind(link_name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
|
||||
.bind(link_name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
match secret_ids.len() {
|
||||
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
|
||||
1 => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(secret_ids[0])
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
n => anyhow::bail!(
|
||||
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
|
||||
n,
|
||||
link_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if existing.is_none() {
|
||||
let history_metadata =
|
||||
match db::metadata_with_secret_snapshot(&mut tx, entry_id, &metadata).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
metadata.clone()
|
||||
}
|
||||
};
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type,
|
||||
name: params.name,
|
||||
version: current_entry_version,
|
||||
action: "create",
|
||||
tags: params.tags,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history on create");
|
||||
}
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
@@ -357,7 +465,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
params.user_id,
|
||||
"add",
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"tags": params.tags,
|
||||
@@ -370,18 +478,47 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(AddResult {
|
||||
entry_id,
|
||||
name: params.name.to_string(),
|
||||
folder: params.folder.to_string(),
|
||||
entry_type: params.entry_type.to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
tags: params.tags.to_vec(),
|
||||
meta_keys,
|
||||
secret_keys,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_link_secret_names(
|
||||
link_secret_names: &[String],
|
||||
new_secret_names: &BTreeSet<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut deduped = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for raw in link_secret_names {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("link_secret_names contains an empty name");
|
||||
}
|
||||
if new_secret_names.contains(trimmed) {
|
||||
anyhow::bail!(
|
||||
"Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names",
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
if seen.insert(trimmed.to_string()) {
|
||||
deduped.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deduped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn parse_nested_file_shorthand() {
|
||||
@@ -410,4 +547,267 @@ mod tests {
|
||||
assert_eq!(fields[1].0, "credentials.type");
|
||||
assert_eq!(fields[2].0, "username");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_link_secret_names_conflict_with_new_secret() {
|
||||
let mut new_names = BTreeSet::new();
|
||||
new_names.insert("password".to_string());
|
||||
let err = validate_link_secret_names(&[String::from("password")], &new_names)
|
||||
.expect_err("must fail on overlap");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("provided both in secrets/secrets_obj and link_secret_names")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_link_secret_names_dedup_and_trim() {
|
||||
let names = vec![
|
||||
" shared_key ".to_string(),
|
||||
"shared_key".to_string(),
|
||||
"runner_token".to_string(),
|
||||
];
|
||||
let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap();
|
||||
assert_eq!(deduped, vec!["shared_key", "runner_token"]);
|
||||
}
|
||||
|
||||
async fn maybe_test_pool() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
|
||||
eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set");
|
||||
return None;
|
||||
};
|
||||
let Ok(pool) = PgPool::connect(&url).await else {
|
||||
eprintln!("skip add linkage tests: cannot connect to database");
|
||||
return None;
|
||||
};
|
||||
if let Err(e) = crate::db::migrate(&pool).await {
|
||||
eprintln!("skip add linkage tests: migrate failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
|
||||
)
|
||||
.bind(format!("%{marker}%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
|
||||
)
|
||||
.bind(format!("%{marker}%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_links_existing_secret_by_unique_name() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("link_unique_{}", &suffix[..8]);
|
||||
let secret_name = format!("{}_secret", marker);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
|
||||
let secret_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id",
|
||||
)
|
||||
.bind(&secret_name)
|
||||
.bind(vec![1_u8, 2, 3])
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let linked: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS( \
|
||||
SELECT 1 FROM entry_secrets es \
|
||||
JOIN entries e ON e.id = es.entry_id \
|
||||
WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \
|
||||
)",
|
||||
)
|
||||
.bind(&entry_name)
|
||||
.bind(secret_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(linked);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_link_secret_name_not_found_fails() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("link_missing_{}", &suffix[..8]);
|
||||
let secret_name = format!("{}_secret", marker);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
|
||||
let err = run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await
|
||||
.expect_err("must fail when linked secret is not found");
|
||||
assert!(err.to_string().contains("Not found: secret named"));
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_link_secret_name_ambiguous_fails() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("link_amb_{}", &suffix[..8]);
|
||||
let secret_name = format!("{}_dup_secret", marker);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
|
||||
)
|
||||
.bind(&secret_name)
|
||||
.bind(vec![1_u8])
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
|
||||
)
|
||||
.bind(&secret_name)
|
||||
.bind(vec![2_u8])
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let err = run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await
|
||||
.expect_err("must fail on ambiguous linked secret name");
|
||||
assert!(err.to_string().contains("Ambiguous:"));
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_duplicate_secret_name_returns_conflict_error() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("dup_secret_{}", &suffix[..8]);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
let secret_name = "shared_token";
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
|
||||
// First add succeeds
|
||||
run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[format!("{}=value1", secret_name)],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: &[],
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Second add with same secret name under same user_id should fail with ConflictSecretName
|
||||
let entry_name2 = format!("{}_entry2", marker);
|
||||
let err = run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name2,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[format!("{}=value2", secret_name)],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: &[],
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await
|
||||
.expect_err("must fail on duplicate secret name");
|
||||
|
||||
let app_err = err
|
||||
.downcast_ref::<crate::error::AppError>()
|
||||
.expect("error should be AppError");
|
||||
assert!(
|
||||
matches!(app_err, crate::error::AppError::ConflictSecretName { .. }),
|
||||
"expected ConflictSecretName, got: {}",
|
||||
app_err
|
||||
);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
const KEY_PREFIX: &str = "sk_";
|
||||
|
||||
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
|
||||
@@ -9,39 +11,50 @@ pub fn generate_api_key() -> String {
|
||||
use rand::RngExt;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
format!("{}{}", KEY_PREFIX, hex)
|
||||
format!("{}{}", KEY_PREFIX, ::hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Return the user's existing API key, or generate and store a new one if NULL.
|
||||
/// Uses a transaction with atomic update to prevent TOCTOU race conditions.
|
||||
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
||||
let existing: Option<(Option<String>,)> =
|
||||
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
if let Some((Some(key),)) = existing {
|
||||
// Lock the row and check existing key
|
||||
let existing: (Option<String>,) =
|
||||
sqlx::query_as("SELECT api_key FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(AppError::NotFoundUser)?;
|
||||
|
||||
if let Some(key) = existing.0 {
|
||||
tx.commit().await?;
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// Generate and store new key atomically
|
||||
let new_key = generate_api_key();
|
||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
||||
.bind(&new_key)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(new_key)
|
||||
}
|
||||
|
||||
/// Generate a fresh API key for the user, replacing the old one.
|
||||
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
||||
let new_key = generate_api_key();
|
||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
||||
let res = sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
||||
.bind(&new_key)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFoundUser.into());
|
||||
}
|
||||
Ok(new_key)
|
||||
}
|
||||
|
||||
@@ -53,3 +66,30 @@ pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uui
|
||||
.await?;
|
||||
Ok(row.map(|(id,)| id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sqlx::PgPool;
|
||||
|
||||
use super::regenerate_api_key;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[tokio::test]
|
||||
async fn regenerate_api_key_unknown_user_returns_not_found() {
|
||||
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
|
||||
return;
|
||||
};
|
||||
let Ok(pool) = PgPool::connect(&url).await else {
|
||||
return;
|
||||
};
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let err = regenerate_api_key(&pool, id)
|
||||
.await
|
||||
.err()
|
||||
.expect("expected error");
|
||||
assert!(matches!(
|
||||
err.downcast_ref::<AppError>(),
|
||||
Some(AppError::NotFoundUser)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,36 @@ use uuid::Uuid;
|
||||
|
||||
use crate::models::AuditLogEntry;
|
||||
|
||||
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
|
||||
pub async fn list_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>> {
|
||||
let limit = limit.clamp(1, 200);
|
||||
let offset = offset.max(0);
|
||||
|
||||
let rows = sqlx::query_as(
|
||||
"SELECT id, user_id, action, folder, type, name, detail, created_at \
|
||||
FROM audit_log \
|
||||
WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC, id DESC \
|
||||
LIMIT $2",
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*)::bigint FROM audit_log WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
||||
use crate::service::util::user_scope_condition;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeletedEntry {
|
||||
@@ -20,6 +22,17 @@ pub struct DeleteResult {
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct TrashEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub deleted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub struct DeleteParams<'a> {
|
||||
/// If set, delete a single entry by name.
|
||||
pub name: Option<&'a str>,
|
||||
@@ -31,12 +44,160 @@ pub struct DeleteParams<'a> {
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Delete a single entry by id (multi-tenant: `user_id` must match). Cascades `secrets` via FK.
|
||||
/// Maximum number of entries that can be deleted in a single bulk operation.
|
||||
/// Prevents accidental mass deletion when filters are too broad.
|
||||
pub const MAX_BULK_DELETE: usize = 1000;
|
||||
|
||||
pub async fn list_deleted_entries(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<TrashEntry>> {
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, folder, type, deleted_at FROM entries \
|
||||
WHERE user_id = $1 AND deleted_at IS NOT NULL \
|
||||
ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
};
|
||||
|
||||
let conflict_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&row.folder)
|
||||
.bind(&row.name)
|
||||
.bind(row.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if conflict_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::ConflictEntryName {
|
||||
folder: row.folder,
|
||||
name: row.name,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"restore",
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
json!({ "entry_id": row.id }),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
};
|
||||
|
||||
purge_entry_record(&mut tx, row.id).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"purge",
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
json!({ "entry_id": row.id }),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result<u64> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExpiredRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows: Vec<ExpiredRow> = sqlx::query_as(
|
||||
"SELECT id FROM entries \
|
||||
WHERE deleted_at IS NOT NULL \
|
||||
AND deleted_at < NOW() - INTERVAL '3 months' \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for row in &rows {
|
||||
purge_entry_record(&mut tx, row.id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(rows.len() as u64)
|
||||
}
|
||||
|
||||
/// Delete a single entry by id (multi-tenant: `user_id` must match).
|
||||
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
@@ -56,7 +217,7 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
|
||||
let name = row.name.clone();
|
||||
let entry_row: EntryRow = (&row).into();
|
||||
|
||||
snapshot_and_delete(
|
||||
snapshot_and_soft_delete(
|
||||
&mut tx,
|
||||
&folder,
|
||||
&entry_type,
|
||||
@@ -127,37 +288,27 @@ async fn delete_one(
|
||||
entry_type: String,
|
||||
}
|
||||
|
||||
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id = $1 AND name = $2")
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id IS NULL AND name = $1")
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
q = q.bind(name);
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
return match rows.len() {
|
||||
0 => Ok(DeleteResult {
|
||||
@@ -165,7 +316,10 @@ async fn delete_one(
|
||||
dry_run: true,
|
||||
}),
|
||||
1 => {
|
||||
let row = rows.into_iter().next().unwrap();
|
||||
let row = rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?;
|
||||
Ok(DeleteResult {
|
||||
deleted: vec![DeletedEntry {
|
||||
name: name.to_string(),
|
||||
@@ -191,45 +345,28 @@ async fn delete_one(
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE {} AND deleted_at IS NULL FOR UPDATE",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
q = q.bind(name);
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
@@ -239,7 +376,10 @@ async fn delete_one(
|
||||
dry_run: false,
|
||||
});
|
||||
}
|
||||
1 => rows.into_iter().next().unwrap(),
|
||||
1 => rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
|
||||
_ => {
|
||||
tx.rollback().await?;
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
@@ -255,7 +395,7 @@ async fn delete_one(
|
||||
|
||||
let folder = row.folder.clone();
|
||||
let entry_type = row.entry_type.clone();
|
||||
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||
snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
user_id,
|
||||
@@ -313,27 +453,29 @@ async fn delete_bulk(
|
||||
}
|
||||
if entry_type.is_some() {
|
||||
conditions.push(format!("type = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} ORDER BY type, name"
|
||||
);
|
||||
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
if let Some(t) = entry_type {
|
||||
q = q.bind(t);
|
||||
}
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
let _ = idx; // used only for placeholder numbering in conditions
|
||||
|
||||
if dry_run {
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
if let Some(t) = entry_type {
|
||||
q = q.bind(t);
|
||||
}
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
let deleted = rows
|
||||
.iter()
|
||||
.map(|r| DeletedEntry {
|
||||
@@ -348,9 +490,37 @@ async fn delete_bulk(
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
if let Some(t) = entry_type {
|
||||
q = q.bind(t);
|
||||
}
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
if rows.len() > MAX_BULK_DELETE {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Bulk delete would affect {} entries (limit: {}). \
|
||||
Narrow your filters or delete entries individually.",
|
||||
rows.len(),
|
||||
MAX_BULK_DELETE,
|
||||
);
|
||||
}
|
||||
|
||||
let mut deleted = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let entry_row = EntryRow {
|
||||
let entry_row: EntryRow = EntryRow {
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
folder: row.folder.clone(),
|
||||
@@ -358,9 +528,9 @@ async fn delete_bulk(
|
||||
tags: row.tags.clone(),
|
||||
metadata: row.metadata.clone(),
|
||||
notes: row.notes.clone(),
|
||||
name: row.name.clone(),
|
||||
};
|
||||
let mut tx = pool.begin().await?;
|
||||
snapshot_and_delete(
|
||||
snapshot_and_soft_delete(
|
||||
&mut tx,
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
@@ -379,7 +549,6 @@ async fn delete_bulk(
|
||||
json!({"bulk": true}),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
deleted.push(DeletedEntry {
|
||||
name: row.name.clone(),
|
||||
folder: row.folder.clone(),
|
||||
@@ -387,13 +556,15 @@ async fn delete_bulk(
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(DeleteResult {
|
||||
deleted,
|
||||
dry_run: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn snapshot_and_delete(
|
||||
async fn snapshot_and_soft_delete(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
folder: &str,
|
||||
entry_type: &str,
|
||||
@@ -401,6 +572,15 @@ async fn snapshot_and_delete(
|
||||
row: &EntryRow,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let history_metadata = match db::metadata_with_secret_snapshot(tx, row.id, &row.metadata).await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
row.metadata.clone()
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
tx,
|
||||
db::EntrySnapshotParams {
|
||||
@@ -412,7 +592,7 @@ async fn snapshot_and_delete(
|
||||
version: row.version,
|
||||
action: "delete",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -420,20 +600,22 @@ async fn snapshot_and_delete(
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
|
||||
}
|
||||
|
||||
let fields: Vec<SecretFieldRow> =
|
||||
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
|
||||
.bind(row.id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let fields: Vec<SecretFieldRow> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(row.id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for f in &fields {
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
tx,
|
||||
db::SecretSnapshotParams {
|
||||
entry_id: row.id,
|
||||
secret_id: f.id,
|
||||
entry_version: row.version,
|
||||
field_name: &f.field_name,
|
||||
name: &f.name,
|
||||
encrypted: &f.encrypted,
|
||||
action: "delete",
|
||||
},
|
||||
@@ -444,10 +626,198 @@ async fn snapshot_and_delete(
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||
sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn purge_entry_record(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let fields: Vec<SecretFieldRow> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
|
||||
if !secret_ids.is_empty() {
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = ANY($1) \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(&secret_ids)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn maybe_test_pool() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
|
||||
eprintln!("skip delete tests: SECRETS_DATABASE_URL is not set");
|
||||
return None;
|
||||
};
|
||||
let Ok(pool) = PgPool::connect(&url).await else {
|
||||
eprintln!("skip delete tests: cannot connect to database");
|
||||
return None;
|
||||
};
|
||||
if let Err(e) = crate::db::migrate(&pool).await {
|
||||
eprintln!("skip delete tests: migrate failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn cleanup_single_user_rows(pool: &PgPool, marker: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
|
||||
)
|
||||
.bind(format!("%{marker}%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
|
||||
)
|
||||
.bind(format!("%{marker}%"))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_dry_run_reports_matching_entry_without_writes() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("delete_dry_{}", &suffix[..8]);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
|
||||
cleanup_single_user_rows(&pool, &marker).await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \
|
||||
VALUES (NULL, $1, 'service', $2, '', '{}', '{}')",
|
||||
)
|
||||
.bind(&marker)
|
||||
.bind(&entry_name)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let result = run(
|
||||
&pool,
|
||||
DeleteParams {
|
||||
name: Some(&entry_name),
|
||||
folder: Some(&marker),
|
||||
entry_type: None,
|
||||
dry_run: true,
|
||||
user_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(result.dry_run);
|
||||
assert_eq!(result.deleted.len(), 1);
|
||||
assert_eq!(result.deleted[0].name, entry_name);
|
||||
|
||||
let still_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2)",
|
||||
)
|
||||
.bind(&marker)
|
||||
.bind(&entry_name)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(still_exists);
|
||||
|
||||
cleanup_single_user_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_id_removes_entry_and_orphan_secret() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("delete_id_{}", &suffix[..8]);
|
||||
let user_id = Uuid::from_u128(rand::random());
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
let secret_name = format!("{}_secret", marker);
|
||||
|
||||
sqlx::query("DELETE FROM entries WHERE user_id = $1 AND folder = $2")
|
||||
.bind(user_id)
|
||||
.bind(&marker)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM secrets WHERE user_id = $1 AND name = $2")
|
||||
.bind(user_id)
|
||||
.bind(&secret_name)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let entry_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \
|
||||
VALUES ($1, $2, 'service', $3, '', '{}', '{}') RETURNING id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&marker)
|
||||
.bind(&entry_name)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let secret_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, 'text', $3) RETURNING id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&secret_name)
|
||||
.bind(vec![1_u8, 2, 3])
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
||||
.bind(entry_id)
|
||||
.bind(secret_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let result = delete_by_id(&pool, entry_id, user_id).await?;
|
||||
assert!(!result.dry_run);
|
||||
assert_eq!(result.deleted.len(), 1);
|
||||
assert_eq!(result.deleted[0].name, entry_name);
|
||||
|
||||
let entry_exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1)")
|
||||
.bind(entry_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let secret_exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM secrets WHERE id = $1)")
|
||||
.bind(secret_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(!entry_exists);
|
||||
assert!(!secret_exists);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Entry;
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
/// Build an env variable map from entry secrets (for dry-run preview or injection).
|
||||
@@ -21,103 +20,53 @@ pub async fn build_env_map(
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, None, user_id).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
|
||||
let mut combined: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for entry in &entries {
|
||||
let entry_map =
|
||||
build_entry_env_map(pool, entry, only_fields, prefix, master_key, user_id).await?;
|
||||
combined.extend(entry_map);
|
||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
|
||||
let fields: Vec<_> = if only_fields.is_empty() {
|
||||
all_fields.iter().collect()
|
||||
} else {
|
||||
all_fields
|
||||
.iter()
|
||||
.filter(|f| only_fields.contains(&f.name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let seg = secret_name_to_env_segment(&f.name);
|
||||
let key = format!("{}_{}", effective_prefix, seg);
|
||||
if let Some(_old) = combined.insert(key.clone(), json_to_env_string(&decrypted)) {
|
||||
anyhow::bail!(
|
||||
"environment variable name collision after normalization: '{}' (secret '{}')",
|
||||
key,
|
||||
f.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn build_entry_env_map(
|
||||
pool: &PgPool,
|
||||
entry: &Entry,
|
||||
only_fields: &[String],
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
|
||||
let fields: Vec<_> = if only_fields.is_empty() {
|
||||
all_fields.iter().collect()
|
||||
} else {
|
||||
all_fields
|
||||
.iter()
|
||||
.filter(|f| only_fields.contains(&f.field_name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_to_env_string(&decrypted));
|
||||
}
|
||||
|
||||
// Resolve key_ref. Supported formats: "name" or "folder/name".
|
||||
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
|
||||
let (ref_folder, ref_name) = if let Some((f, n)) = key_ref.split_once('/') {
|
||||
(Some(f), n)
|
||||
} else {
|
||||
(None, key_ref)
|
||||
};
|
||||
let key_entries = fetch_entries(
|
||||
pool,
|
||||
ref_folder,
|
||||
Some("key"),
|
||||
Some(ref_name),
|
||||
&[],
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if key_entries.len() > 1 {
|
||||
anyhow::bail!(
|
||||
"key_ref '{}' matched {} entries; qualify with folder/name to resolve the ambiguity",
|
||||
key_ref,
|
||||
key_entries.len()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(key_entry) = key_entries.first() {
|
||||
let key_ids = vec![key_entry.id];
|
||||
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
|
||||
let empty = vec![];
|
||||
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
|
||||
let key_prefix = env_prefix(key_entry, prefix);
|
||||
for f in key_fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key_var = format!(
|
||||
"{}_{}",
|
||||
key_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key_var, json_to_env_string(&decrypted));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(key_ref, ?user_id, "key_ref target not found");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
/// Map a secret field name to an env key segment: `.` → `__`, `-` → `_`, then uppercase.
|
||||
/// Avoids collisions between e.g. `db.password` and `db_password`.
|
||||
fn secret_name_to_env_segment(name: &str) -> String {
|
||||
name.replace('.', "__").replace('-', "_").to_uppercase()
|
||||
}
|
||||
|
||||
fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
||||
fn env_prefix(entry: &crate::models::Entry, prefix: &str) -> String {
|
||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
@@ -135,3 +84,39 @@ fn json_to_env_string(v: &Value) -> String {
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{env_prefix, secret_name_to_env_segment};
|
||||
use crate::models::Entry;
|
||||
|
||||
#[test]
|
||||
fn secret_name_env_segment_disambiguates_dot_from_underscore() {
|
||||
assert_eq!(secret_name_to_env_segment("db.password"), "DB__PASSWORD");
|
||||
assert_eq!(secret_name_to_env_segment("db_password"), "DB_PASSWORD");
|
||||
assert_eq!(secret_name_to_env_segment("api-key"), "API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_prefix_with_and_without_prefix() {
|
||||
let entry = Entry {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
user_id: None,
|
||||
folder: "test".into(),
|
||||
entry_type: "server".into(),
|
||||
name: "my-server".into(),
|
||||
notes: String::new(),
|
||||
tags: vec![],
|
||||
metadata: Value::Null,
|
||||
version: 1,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
deleted_at: None,
|
||||
};
|
||||
assert_eq!(env_prefix(&entry, ""), "MY_SERVER");
|
||||
assert_eq!(env_prefix(&entry, "ALIYUN"), "ALIYUN_MY_SERVER");
|
||||
assert_eq!(env_prefix(&entry, "aliyun_"), "ALIYUN_MY_SERVER");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub async fn export(
|
||||
params.name,
|
||||
params.tags,
|
||||
params.query,
|
||||
None,
|
||||
params.user_id,
|
||||
)
|
||||
.await?;
|
||||
@@ -43,21 +44,23 @@ pub async fn export(
|
||||
|
||||
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
let secrets = if params.no_secrets {
|
||||
None
|
||||
let (secrets, secret_types) = if params.no_secrets {
|
||||
(None, None)
|
||||
} else {
|
||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if fields.is_empty() {
|
||||
Some(BTreeMap::new())
|
||||
(Some(BTreeMap::new()), Some(BTreeMap::new()))
|
||||
} else {
|
||||
let mk = master_key
|
||||
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
||||
let mut map = BTreeMap::new();
|
||||
let mut type_map = BTreeMap::new();
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
||||
map.insert(f.field_name.clone(), decrypted);
|
||||
map.insert(f.name.clone(), decrypted);
|
||||
type_map.insert(f.name.clone(), f.secret_type.clone());
|
||||
}
|
||||
Some(map)
|
||||
(Some(map), Some(type_map))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,6 +72,7 @@ pub async fn export(
|
||||
tags: entry.tags.clone(),
|
||||
metadata: entry.metadata.clone(),
|
||||
secrets,
|
||||
secret_types,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::error::AppError;
|
||||
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
|
||||
|
||||
/// Decrypt a single named field from an entry.
|
||||
@@ -25,7 +26,7 @@ pub async fn get_secret_field(
|
||||
|
||||
let field = fields
|
||||
.iter()
|
||||
.find(|f| f.field_name == field_name)
|
||||
.find(|f| f.name == field_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
|
||||
|
||||
crypto::decrypt_json(master_key, &field.encrypted)
|
||||
@@ -49,7 +50,7 @@ pub async fn get_all_secrets(
|
||||
let mut map = HashMap::new();
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
map.insert(f.field_name.clone(), decrypted);
|
||||
map.insert(f.name.clone(), decrypted);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -64,7 +65,7 @@ pub async fn get_secret_field_by_id(
|
||||
) -> Result<Value> {
|
||||
resolve_entry_by_id(pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
||||
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
|
||||
|
||||
let entry_ids = vec![entry_id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
@@ -72,7 +73,7 @@ pub async fn get_secret_field_by_id(
|
||||
|
||||
let field = fields
|
||||
.iter()
|
||||
.find(|f| f.field_name == field_name)
|
||||
.find(|f| f.name == field_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
|
||||
|
||||
crypto::decrypt_json(master_key, &field.encrypted)
|
||||
@@ -89,7 +90,7 @@ pub async fn get_all_secrets_by_id(
|
||||
// Validate entry exists (and that it belongs to the requesting user)
|
||||
resolve_entry_by_id(pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
||||
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
|
||||
|
||||
let entry_ids = vec![entry_id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
@@ -98,7 +99,7 @@ pub async fn get_all_secrets_by_id(
|
||||
let mut map = HashMap::new();
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
map.insert(f.field_name.clone(), decrypted);
|
||||
map.insert(f.name.clone(), decrypted);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,11 @@ pub async fn run(
|
||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
"SELECT version, action, created_at FROM entries_history \
|
||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
|
||||
"SELECT DISTINCT ON (version) version, action, created_at \
|
||||
FROM entries_history \
|
||||
WHERE entry_id = $1 \
|
||||
ORDER BY version DESC, id DESC \
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(entry.id)
|
||||
.bind(limit as i64)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::ExportFormat;
|
||||
@@ -54,7 +55,13 @@ pub async fn run(
|
||||
.bind(params.user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to check entry existence for '{}': {}",
|
||||
entry.name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if exists && !params.force {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -74,6 +81,11 @@ pub async fn run(
|
||||
|
||||
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
||||
let meta_entries = build_meta_entries(&entry.metadata);
|
||||
let secret_types_map: HashMap<String, String> = entry
|
||||
.secret_types
|
||||
.as_ref()
|
||||
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
match add_run(
|
||||
pool,
|
||||
@@ -85,6 +97,8 @@ pub async fn run(
|
||||
tags: &entry.tags,
|
||||
meta_entries: &meta_entries,
|
||||
secret_entries: &secret_entries,
|
||||
secret_types: &secret_types_map,
|
||||
link_secret_names: &[],
|
||||
user_id: params.user_id,
|
||||
},
|
||||
master_key,
|
||||
@@ -117,3 +131,50 @@ pub async fn run(
|
||||
dry_run: params.dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use crate::models::ExportEntry;
|
||||
|
||||
/// Mirrors the map built in `run` before `AddParams` (legacy files omit `secret_types`).
|
||||
fn secret_types_for_add(entry: &ExportEntry) -> HashMap<String, String> {
|
||||
entry
|
||||
.secret_types
|
||||
.as_ref()
|
||||
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_types_three_kinds_round_trip_for_add_params() {
|
||||
let mut types = BTreeMap::new();
|
||||
types.insert("p".into(), "password".into());
|
||||
types.insert("k".into(), "key".into());
|
||||
types.insert("t".into(), "text".into());
|
||||
let entry = ExportEntry {
|
||||
name: "n".into(),
|
||||
folder: "f".into(),
|
||||
entry_type: "ty".into(),
|
||||
notes: "".into(),
|
||||
tags: vec![],
|
||||
metadata: serde_json::json!({}),
|
||||
secrets: Some(BTreeMap::new()),
|
||||
secret_types: Some(types),
|
||||
};
|
||||
let m = secret_types_for_add(&entry);
|
||||
assert_eq!(m.get("p").map(String::as_str), Some("password"));
|
||||
assert_eq!(m.get("k").map(String::as_str), Some("key"));
|
||||
assert_eq!(m.get("t").map(String::as_str), Some("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_types_absent_defaults_to_empty_map_like_legacy_export() {
|
||||
let json =
|
||||
r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{}}"#;
|
||||
let entry: ExportEntry = serde_json::from_str(json).unwrap();
|
||||
assert!(entry.secret_types.is_none());
|
||||
assert!(secret_types_for_add(&entry).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ pub mod export;
|
||||
pub mod get_secret;
|
||||
pub mod history;
|
||||
pub mod import;
|
||||
pub mod relations;
|
||||
pub mod rollback;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
pub mod user;
|
||||
pub mod util;
|
||||
|
||||
324
crates/secrets-core/src/service/relations.rs
Normal file
324
crates/secrets-core/src/service/relations.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RelationEntrySummary {
|
||||
pub id: Uuid,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
#[sqlx(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct EntryRelations {
|
||||
pub parents: Vec<RelationEntrySummary>,
|
||||
pub children: Vec<RelationEntrySummary>,
|
||||
}
|
||||
|
||||
pub async fn add_parent_relation(
|
||||
pool: &PgPool,
|
||||
parent_entry_id: Uuid,
|
||||
child_entry_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
if parent_entry_id == child_entry_id {
|
||||
return Err(AppError::Validation {
|
||||
message: "entry cannot reference itself".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||
|
||||
let cycle_exists: bool = sqlx::query_scalar(
|
||||
"WITH RECURSIVE descendants AS ( \
|
||||
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||
UNION \
|
||||
SELECT er.child_entry_id \
|
||||
FROM entry_relations er \
|
||||
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||
) \
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||
)
|
||||
.bind(child_entry_id)
|
||||
.bind(parent_entry_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if cycle_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::Validation {
|
||||
message: "adding this relation would create a cycle".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) \
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(parent_entry_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_parent_relation(
|
||||
pool: &PgPool,
|
||||
parent_entry_id: Uuid,
|
||||
child_entry_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||
sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2")
|
||||
.bind(parent_entry_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_parent_relations(
|
||||
pool: &PgPool,
|
||||
child_entry_id: Uuid,
|
||||
parent_entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let deduped: Vec<Uuid> = parent_entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if deduped.contains(&child_entry_id) {
|
||||
return Err(AppError::Validation {
|
||||
message: "entry cannot reference itself".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut validate_ids = Vec::with_capacity(deduped.len() + 1);
|
||||
validate_ids.push(child_entry_id);
|
||||
validate_ids.extend(deduped.iter().copied());
|
||||
validate_live_entries(&mut tx, &validate_ids, user_id).await?;
|
||||
|
||||
let current_parent_ids: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1")
|
||||
.bind(child_entry_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let current: BTreeSet<Uuid> = current_parent_ids.into_iter().collect();
|
||||
let target: BTreeSet<Uuid> = deduped.iter().copied().collect();
|
||||
|
||||
for parent_id in current.difference(&target) {
|
||||
sqlx::query(
|
||||
"DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2",
|
||||
)
|
||||
.bind(*parent_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for parent_id in target.difference(¤t) {
|
||||
let cycle_exists: bool = sqlx::query_scalar(
|
||||
"WITH RECURSIVE descendants AS ( \
|
||||
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||
UNION \
|
||||
SELECT er.child_entry_id \
|
||||
FROM entry_relations er \
|
||||
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||
) \
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||
)
|
||||
.bind(child_entry_id)
|
||||
.bind(*parent_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if cycle_exists {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::Validation {
|
||||
message: "adding this relation would create a cycle".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(*parent_id)
|
||||
.bind(child_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_relations_for_entries(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<Uuid, EntryRelations>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ParentRow {
|
||||
owner_entry_id: Uuid,
|
||||
id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ChildRow {
|
||||
owner_entry_id: Uuid,
|
||||
id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let (parents, children): (Vec<ParentRow>, Vec<ChildRow>) = if let Some(uid) = user_id {
|
||||
let parents = sqlx::query_as(
|
||||
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
WHERE er.child_entry_id = ANY($1) \
|
||||
AND p.user_id = $2 AND c.user_id = $2 \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.child_entry_id, p.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.bind(uid)
|
||||
.fetch_all(pool);
|
||||
let children = sqlx::query_as(
|
||||
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
WHERE er.parent_entry_id = ANY($1) \
|
||||
AND p.user_id = $2 AND c.user_id = $2 \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.parent_entry_id, c.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.bind(uid)
|
||||
.fetch_all(pool);
|
||||
(parents.await?, children.await?)
|
||||
} else {
|
||||
let parents = sqlx::query_as(
|
||||
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
WHERE er.child_entry_id = ANY($1) \
|
||||
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.child_entry_id, p.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool);
|
||||
let children = sqlx::query_as(
|
||||
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||
FROM entry_relations er \
|
||||
JOIN entries c ON c.id = er.child_entry_id \
|
||||
JOIN entries p ON p.id = er.parent_entry_id \
|
||||
WHERE er.parent_entry_id = ANY($1) \
|
||||
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||
ORDER BY er.parent_entry_id, c.name ASC",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool);
|
||||
(parents.await?, children.await?)
|
||||
};
|
||||
|
||||
let mut map: HashMap<Uuid, EntryRelations> = entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|id| (id, EntryRelations::default()))
|
||||
.collect();
|
||||
|
||||
for row in parents {
|
||||
map.entry(row.owner_entry_id)
|
||||
.or_default()
|
||||
.parents
|
||||
.push(RelationEntrySummary {
|
||||
id: row.id,
|
||||
folder: row.folder,
|
||||
entry_type: row.entry_type,
|
||||
name: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
for row in children {
|
||||
map.entry(row.owner_entry_id)
|
||||
.or_default()
|
||||
.children
|
||||
.push(RelationEntrySummary {
|
||||
id: row.id,
|
||||
folder: row.folder,
|
||||
entry_type: row.entry_type,
|
||||
name: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn validate_live_entries(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
let unique_ids: Vec<Uuid> = entry_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let live_count: i64 = if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::bigint FROM entries \
|
||||
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&unique_ids)
|
||||
.bind(uid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::bigint FROM entries \
|
||||
WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&unique_ids)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
if live_count != unique_ids.len() as i64 {
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::EntryWriteRow;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct RollbackResult {
|
||||
@@ -16,194 +19,91 @@ pub struct RollbackResult {
|
||||
}
|
||||
|
||||
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
|
||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
entry_id: Uuid,
|
||||
to_version: Option<i64>,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<RollbackResult> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryHistoryRow {
|
||||
entry_id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
version: i64,
|
||||
action: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
// Disambiguate: find the unique entry_id for (name, folder).
|
||||
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
|
||||
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(f)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let live: Option<EntryWriteRow> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(f)
|
||||
.fetch_optional(pool)
|
||||
.bind(entry_id)
|
||||
.bind(uid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
.bind(entry_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
|
||||
let lr = live.ok_or(AppError::NotFoundEntry)?;
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, folder, type, version, action, tags, metadata \
|
||||
"SELECT folder, type, name, version, action, tags, metadata \
|
||||
FROM entries_history \
|
||||
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
|
||||
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(ver)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, folder, type, version, action, tags, metadata \
|
||||
"SELECT folder, type, name, version, action, tags, metadata \
|
||||
FROM entries_history \
|
||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let snap = snap.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No history found for '{}'{}.",
|
||||
name,
|
||||
"No history found for entry '{}'{}.",
|
||||
lr.name,
|
||||
to_version
|
||||
.map(|v| format!(" at version {}", v))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SecretHistoryRow {
|
||||
field_name: String,
|
||||
encrypted: Vec<u8>,
|
||||
action: String,
|
||||
}
|
||||
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
|
||||
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
|
||||
|
||||
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
|
||||
"SELECT field_name, encrypted, action FROM secrets_history \
|
||||
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
|
||||
)
|
||||
.bind(snap.entry_id)
|
||||
.bind(snap.version)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let live_entry_id = {
|
||||
let history_metadata =
|
||||
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
lr.metadata.clone()
|
||||
}
|
||||
};
|
||||
|
||||
for f in &field_snaps {
|
||||
if f.action != "delete" && !f.encrypted.is_empty() {
|
||||
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Cannot decrypt snapshot for field '{}': {}",
|
||||
f.field_name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveEntry {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
#[allow(dead_code)]
|
||||
notes: String,
|
||||
}
|
||||
|
||||
// Lock the live entry if it exists (matched by entry_id for precision).
|
||||
let live: Option<LiveEntry> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let live_entry_id = if let Some(ref lr) = live {
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
@@ -211,11 +111,11 @@ pub async fn run(
|
||||
user_id,
|
||||
folder: &lr.folder,
|
||||
entry_type: &lr.entry_type,
|
||||
name,
|
||||
name: &lr.name,
|
||||
version: lr.version,
|
||||
action: "rollback",
|
||||
tags: &lr.tags,
|
||||
metadata: &lr.metadata,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -226,23 +126,25 @@ pub async fn run(
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveField {
|
||||
id: Uuid,
|
||||
field_name: String,
|
||||
name: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let live_fields: Vec<LiveField> =
|
||||
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
|
||||
.bind(lr.id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let live_fields: Vec<LiveField> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(lr.id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for f in &live_fields {
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
&mut tx,
|
||||
db::SecretSnapshotParams {
|
||||
entry_id: lr.id,
|
||||
secret_id: f.id,
|
||||
entry_version: lr.version,
|
||||
field_name: &f.field_name,
|
||||
name: &f.name,
|
||||
encrypted: &f.encrypted,
|
||||
action: "rollback",
|
||||
},
|
||||
@@ -254,64 +156,24 @@ pub async fn run(
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
|
||||
updated_at = NOW() WHERE id = $3",
|
||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||
version = version + 1, updated_at = NOW() WHERE id = $7",
|
||||
)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(&snap.name)
|
||||
.bind(&lr.notes)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(&snap_metadata)
|
||||
.bind(lr.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
lr.id
|
||||
} else {
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(snap.version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.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(live_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for f in &field_snaps {
|
||||
if f.action == "delete" {
|
||||
continue;
|
||||
}
|
||||
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
|
||||
.bind(live_entry_id)
|
||||
.bind(&f.field_name)
|
||||
.bind(&f.encrypted)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if let Some(secret_snapshot) = snap_secret_snapshot {
|
||||
restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?;
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
@@ -320,8 +182,9 @@ pub async fn run(
|
||||
"rollback",
|
||||
&snap.folder,
|
||||
&snap.entry_type,
|
||||
name,
|
||||
&snap.name,
|
||||
serde_json::json!({
|
||||
"entry_id": entry_id,
|
||||
"restored_version": snap.version,
|
||||
"original_action": snap.action,
|
||||
}),
|
||||
@@ -331,9 +194,150 @@ pub async fn run(
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(RollbackResult {
|
||||
name: name.to_string(),
|
||||
name: snap.name,
|
||||
folder: snap.folder,
|
||||
entry_type: snap.entry_type,
|
||||
restored_version: snap.version,
|
||||
})
|
||||
}
|
||||
|
||||
async fn restore_entry_secrets(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
entry_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
snapshot: &[db::EntrySecretSnapshot],
|
||||
) -> Result<()> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LinkedSecret {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
let linked: Vec<LinkedSecret> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect();
|
||||
|
||||
for s in &linked {
|
||||
if target_names.contains(s.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
tx,
|
||||
db::SecretSnapshotParams {
|
||||
secret_id: s.id,
|
||||
name: &s.name,
|
||||
encrypted: &s.encrypted,
|
||||
action: "rollback",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink");
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
|
||||
.bind(entry_id)
|
||||
.bind(s.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(s.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for snap in snapshot {
|
||||
let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| {
|
||||
anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e)
|
||||
})?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingSecret {
|
||||
id: Uuid,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
let existing: Option<ExistingSecret> = if let Some(uid) = user_id {
|
||||
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2")
|
||||
.bind(uid)
|
||||
.bind(&snap.name)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1")
|
||||
.bind(&snap.name)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let secret_id = if let Some(ex) = existing {
|
||||
if ex.encrypted != encrypted
|
||||
&& let Err(e) = db::snapshot_secret_history(
|
||||
tx,
|
||||
db::SecretSnapshotParams {
|
||||
secret_id: ex.id,
|
||||
name: &snap.name,
|
||||
encrypted: &ex.encrypted,
|
||||
action: "rollback",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret before rollback restore");
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $3",
|
||||
)
|
||||
.bind(&snap.secret_type)
|
||||
.bind(&encrypted)
|
||||
.bind(ex.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
ex.id
|
||||
} else if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(&snap.name)
|
||||
.bind(&snap.secret_type)
|
||||
.bind(&encrypted)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id",
|
||||
)
|
||||
.bind(&snap.name)
|
||||
.bind(&snap.secret_type)
|
||||
.bind(&encrypted)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(secret_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,14 +6,28 @@ use uuid::Uuid;
|
||||
|
||||
use crate::models::{Entry, SecretField};
|
||||
|
||||
pub const FETCH_ALL_LIMIT: u32 = 100_000;
|
||||
pub const FETCH_ALL_LIMIT: u32 = 10_000;
|
||||
|
||||
/// Build an ILIKE pattern for fuzzy matching, escaping `%` and `_` literals.
|
||||
pub fn ilike_pattern(value: &str) -> String {
|
||||
format!(
|
||||
"%{}%",
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
)
|
||||
}
|
||||
|
||||
pub struct SearchParams<'a> {
|
||||
pub folder: Option<&'a str>,
|
||||
pub entry_type: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
/// Fuzzy match on `entries.name` only (ILIKE with escaped `%`/`_`).
|
||||
pub name_query: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub metadata_query: Option<&'a str>,
|
||||
pub sort: &'a str,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
@@ -51,11 +65,19 @@ pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
|
||||
if let Some(v) = a.name {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.name_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
for tag in a.tags {
|
||||
q = q.bind(tag);
|
||||
}
|
||||
if let Some(v) = a.query {
|
||||
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
if let Some(v) = a.metadata_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
let n = q.fetch_one(pool).await?;
|
||||
@@ -73,6 +95,7 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
||||
} else {
|
||||
conditions.push("user_id IS NULL".to_string());
|
||||
}
|
||||
conditions.push("deleted_at IS NULL".to_string());
|
||||
|
||||
if a.folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
@@ -86,6 +109,10 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if a.name_query.is_some() {
|
||||
conditions.push(format!("name ILIKE ${} ESCAPE '\\'", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if !a.tags.is_empty() {
|
||||
let placeholders: Vec<String> = a
|
||||
.tags
|
||||
@@ -111,6 +138,14 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
||||
));
|
||||
idx += 1;
|
||||
}
|
||||
if a.metadata_query.is_some() {
|
||||
conditions.push(format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
|
||||
WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')",
|
||||
idx
|
||||
));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
@@ -124,7 +159,7 @@ pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult
|
||||
let entries = fetch_entries_paged(pool, ¶ms).await?;
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secret_schemas = if !entry_ids.is_empty() {
|
||||
fetch_secret_schemas(pool, &entry_ids).await?
|
||||
fetch_secrets_for_entries(pool, &entry_ids).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
@@ -135,6 +170,7 @@ pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult
|
||||
}
|
||||
|
||||
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fetch_entries(
|
||||
pool: &PgPool,
|
||||
folder: Option<&str>,
|
||||
@@ -142,14 +178,17 @@ pub async fn fetch_entries(
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
metadata_query: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<Entry>> {
|
||||
let params = SearchParams {
|
||||
folder,
|
||||
entry_type,
|
||||
name,
|
||||
name_query: None,
|
||||
tags,
|
||||
query,
|
||||
metadata_query,
|
||||
sort: "name",
|
||||
limit: FETCH_ALL_LIMIT,
|
||||
offset: 0,
|
||||
@@ -172,7 +211,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at \
|
||||
created_at, updated_at, deleted_at \
|
||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||
);
|
||||
|
||||
@@ -189,11 +228,19 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
if let Some(v) = a.name {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.name_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
for tag in a.tags {
|
||||
q = q.bind(tag);
|
||||
}
|
||||
if let Some(v) = a.query {
|
||||
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
if let Some(v) = a.metadata_query {
|
||||
let pattern = ilike_pattern(v);
|
||||
q = q.bind(pattern);
|
||||
}
|
||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
||||
@@ -202,28 +249,6 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
Ok(rows.into_iter().map(Entry::from).collect())
|
||||
}
|
||||
|
||||
/// Fetch secret field names for a set of entry ids (no decryption).
|
||||
pub async fn fetch_secret_schemas(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let fields: Vec<SecretField> = sqlx::query_as(
|
||||
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
|
||||
pub async fn fetch_secrets_for_entries(
|
||||
pool: &PgPool,
|
||||
@@ -232,8 +257,12 @@ pub async fn fetch_secrets_for_entries(
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let fields: Vec<SecretField> = sqlx::query_as(
|
||||
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
|
||||
let fields: Vec<EntrySecretRow> = sqlx::query_as(
|
||||
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = ANY($1) \
|
||||
ORDER BY es.entry_id, es.sort_order, s.name",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
@@ -241,7 +270,8 @@ pub async fn fetch_secrets_for_entries(
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
let entry_id = f.entry_id;
|
||||
map.entry(entry_id).or_default().push(f.secret());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -257,7 +287,7 @@ pub async fn resolve_entry_by_id(
|
||||
let row: Option<EntryRaw> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
|
||||
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(uid)
|
||||
@@ -266,7 +296,7 @@ pub async fn resolve_entry_by_id(
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
|
||||
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -288,7 +318,7 @@ pub async fn resolve_entry(
|
||||
folder: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<crate::models::Entry> {
|
||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
|
||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?;
|
||||
match entries.len() {
|
||||
0 => {
|
||||
if let Some(f) = folder {
|
||||
@@ -297,7 +327,10 @@ pub async fn resolve_entry(
|
||||
anyhow::bail!("Not found: '{}'", name)
|
||||
}
|
||||
}
|
||||
1 => Ok(entries.into_iter().next().unwrap()),
|
||||
1 => entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: resolve_entry result vanished")),
|
||||
_ => {
|
||||
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
@@ -326,6 +359,7 @@ struct EntryRaw {
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl From<EntryRaw> for Entry {
|
||||
@@ -342,6 +376,46 @@ impl From<EntryRaw> for Entry {
|
||||
version: r.version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
deleted_at: r.deleted_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntrySecretRow {
|
||||
entry_id: Uuid,
|
||||
id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
name: String,
|
||||
#[sqlx(rename = "type")]
|
||||
secret_type: String,
|
||||
encrypted: Vec<u8>,
|
||||
version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl EntrySecretRow {
|
||||
fn secret(self) -> SecretField {
|
||||
SecretField {
|
||||
id: self.id,
|
||||
user_id: self.user_id,
|
||||
name: self.name,
|
||||
secret_type: self.secret_type,
|
||||
encrypted: self.encrypted,
|
||||
version: self.version,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ilike_pattern_escapes_backslash_percent_and_underscore() {
|
||||
assert_eq!(ilike_pattern(r"hello\_100%"), r"%hello\\\_100\%%");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::error::{AppError, DbErrorContext};
|
||||
use crate::models::{EntryRow, EntryWriteRow};
|
||||
use crate::service::add::{
|
||||
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
||||
parse_kv, remove_path,
|
||||
};
|
||||
use crate::service::util::user_scope_condition;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UpdateResult {
|
||||
@@ -23,6 +25,8 @@ pub struct UpdateResult {
|
||||
pub remove_meta: Vec<String>,
|
||||
pub secret_keys: Vec<String>,
|
||||
pub remove_secrets: Vec<String>,
|
||||
pub linked_secrets: Vec<String>,
|
||||
pub unlinked_secrets: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct UpdateParams<'a> {
|
||||
@@ -35,7 +39,10 @@ pub struct UpdateParams<'a> {
|
||||
pub meta_entries: &'a [String],
|
||||
pub remove_meta: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
pub secret_types: &'a std::collections::HashMap<String, String>,
|
||||
pub remove_secrets: &'a [String],
|
||||
pub link_secret_names: &'a [String],
|
||||
pub unlink_secret_names: &'a [String],
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -44,58 +51,44 @@ pub async fn run(
|
||||
params: UpdateParams<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<UpdateResult> {
|
||||
if params.name.chars().count() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
|
||||
if let Some(folder) = params.folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(folder)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(folder) = params.folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(folder)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(params.user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if params.folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||
WHERE {} AND deleted_at IS NULL FOR UPDATE",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||
if let Some(uid) = params.user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(folder) = params.folder {
|
||||
q = q.bind(folder);
|
||||
}
|
||||
q = q.bind(params.name);
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Not found: '{}'. Use `add` to create it first.",
|
||||
params.name
|
||||
)
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
1 => rows.into_iter().next().unwrap(),
|
||||
1 => rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
|
||||
_ => {
|
||||
tx.rollback().await?;
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
@@ -109,6 +102,15 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
let history_metadata =
|
||||
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
row.metadata.clone()
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
@@ -120,7 +122,7 @@ pub async fn run(
|
||||
version: row.version,
|
||||
action: "update",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -167,14 +169,9 @@ pub async fn run(
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Concurrent modification detected for '{}'. Please retry.",
|
||||
params.name
|
||||
);
|
||||
return Err(AppError::ConcurrentModification.into());
|
||||
}
|
||||
|
||||
let new_version = row.version + 1;
|
||||
|
||||
for entry in params.secret_entries {
|
||||
let (path, field_value) = parse_kv(entry)?;
|
||||
let flat = flatten_json_fields("", &{
|
||||
@@ -192,7 +189,10 @@ pub async fn run(
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let ef: Option<ExistingField> = sqlx::query_as(
|
||||
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
||||
"SELECT s.id, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1 AND s.name = $2",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(field_name)
|
||||
@@ -203,10 +203,8 @@ pub async fn run(
|
||||
&& let Err(e) = db::snapshot_secret_history(
|
||||
&mut tx,
|
||||
db::SecretSnapshotParams {
|
||||
entry_id: row.id,
|
||||
secret_id: ef.id,
|
||||
entry_version: row.version,
|
||||
field_name,
|
||||
name: field_name,
|
||||
encrypted: &ef.encrypted,
|
||||
action: "update",
|
||||
},
|
||||
@@ -216,16 +214,36 @@ pub async fn run(
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field history");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
|
||||
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(field_name)
|
||||
.bind(&encrypted)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if let Some(ef) = ef {
|
||||
sqlx::query(
|
||||
"UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2",
|
||||
)
|
||||
.bind(&encrypted)
|
||||
.bind(ef.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
let secret_type = params
|
||||
.secret_types
|
||||
.get(field_name)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("text");
|
||||
let secret_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(params.user_id)
|
||||
.bind(field_name.to_string())
|
||||
.bind(secret_type)
|
||||
.bind(&encrypted)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?;
|
||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
||||
.bind(row.id)
|
||||
.bind(secret_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +257,10 @@ pub async fn run(
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let field: Option<FieldToDelete> = sqlx::query_as(
|
||||
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
||||
"SELECT s.id, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1 AND s.name = $2",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(&field_name)
|
||||
@@ -250,10 +271,8 @@ pub async fn run(
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
&mut tx,
|
||||
db::SecretSnapshotParams {
|
||||
entry_id: row.id,
|
||||
secret_id: f.id,
|
||||
entry_version: new_version,
|
||||
field_name: &field_name,
|
||||
name: &field_name,
|
||||
encrypted: &f.encrypted,
|
||||
action: "delete",
|
||||
},
|
||||
@@ -262,10 +281,114 @@ pub async fn run(
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
|
||||
}
|
||||
sqlx::query("DELETE FROM secrets WHERE id = $1")
|
||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
|
||||
.bind(row.id)
|
||||
.bind(f.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(f.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Link existing secrets by name
|
||||
let mut linked_secrets = Vec::new();
|
||||
for link_name in params.link_secret_names {
|
||||
let link_name = link_name.trim();
|
||||
if link_name.is_empty() {
|
||||
anyhow::bail!("link_secret_names contains an empty name");
|
||||
}
|
||||
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
|
||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
|
||||
.bind(uid)
|
||||
.bind(link_name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
|
||||
.bind(link_name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
match secret_ids.len() {
|
||||
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
|
||||
1 => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(secret_ids[0])
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
linked_secrets.push(link_name.to_string());
|
||||
}
|
||||
n => anyhow::bail!(
|
||||
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
|
||||
n,
|
||||
link_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Unlink secrets by name
|
||||
let mut unlinked_secrets = Vec::new();
|
||||
for unlink_name in params.unlink_secret_names {
|
||||
let unlink_name = unlink_name.trim();
|
||||
if unlink_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SecretToUnlink {
|
||||
id: Uuid,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let secret: Option<SecretToUnlink> = sqlx::query_as(
|
||||
"SELECT s.id, s.encrypted \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = $1 AND s.name = $2",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(unlink_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(s) = secret {
|
||||
if let Err(e) = db::snapshot_secret_history(
|
||||
&mut tx,
|
||||
db::SecretSnapshotParams {
|
||||
secret_id: s.id,
|
||||
name: unlink_name,
|
||||
encrypted: &s.encrypted,
|
||||
action: "delete",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot secret field history before unlink");
|
||||
}
|
||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
|
||||
.bind(row.id)
|
||||
.bind(s.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(s.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
unlinked_secrets.push(unlink_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,8 +401,8 @@ pub async fn run(
|
||||
&mut tx,
|
||||
params.user_id,
|
||||
"update",
|
||||
"",
|
||||
"",
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"add_tags": params.add_tags,
|
||||
@@ -288,6 +411,8 @@ pub async fn run(
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
"linked_secrets": linked_secrets,
|
||||
"unlinked_secrets": unlinked_secrets,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -304,6 +429,8 @@ pub async fn run(
|
||||
remove_meta: remove_meta_keys,
|
||||
secret_keys,
|
||||
remove_secrets: remove_secret_keys,
|
||||
linked_secrets,
|
||||
unlinked_secrets,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,21 +451,21 @@ pub async fn update_fields_by_id(
|
||||
user_id: Uuid,
|
||||
params: UpdateEntryFieldsByIdParams<'_>,
|
||||
) -> Result<()> {
|
||||
if params.folder.len() > 128 {
|
||||
if params.folder.chars().count() > 128 {
|
||||
anyhow::bail!("folder must be at most 128 characters");
|
||||
}
|
||||
if params.entry_type.len() > 64 {
|
||||
if params.entry_type.chars().count() > 64 {
|
||||
anyhow::bail!("type must be at most 64 characters");
|
||||
}
|
||||
if params.name.len() > 256 {
|
||||
if params.name.chars().count() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
@@ -349,10 +476,19 @@ pub async fn update_fields_by_id(
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!("Entry not found");
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
};
|
||||
|
||||
let history_metadata =
|
||||
match db::metadata_with_secret_snapshot(&mut tx, row.id, &row.metadata).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to build secret snapshot for entry history");
|
||||
row.metadata.clone()
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
@@ -364,7 +500,7 @@ pub async fn update_fields_by_id(
|
||||
version: row.version,
|
||||
action: "update",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
metadata: &history_metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -372,13 +508,15 @@ pub async fn update_fields_by_id(
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
|
||||
}
|
||||
|
||||
let entry_type = params.entry_type.trim();
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||
version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $7 AND version = $8",
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -391,16 +529,17 @@ pub async fn update_fields_by_id(
|
||||
if let sqlx::Error::Database(ref d) = e
|
||||
&& d.code().as_deref() == Some("23505")
|
||||
{
|
||||
return anyhow::anyhow!(
|
||||
"An entry with this folder and name already exists for your account."
|
||||
);
|
||||
return AppError::ConflictEntryName {
|
||||
folder: params.folder.to_string(),
|
||||
name: params.name.to_string(),
|
||||
};
|
||||
}
|
||||
e.into()
|
||||
AppError::Internal(e.into())
|
||||
})?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!("Concurrent modification detected. Please refresh and try again.");
|
||||
return Err(AppError::ConcurrentModification.into());
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
@@ -408,7 +547,7 @@ pub async fn update_fields_by_id(
|
||||
Some(user_id),
|
||||
"update",
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"source": "web",
|
||||
|
||||
@@ -16,24 +16,28 @@ pub struct OAuthProfile {
|
||||
/// Find or create a user from an OAuth profile.
|
||||
/// Returns (user, is_new) where is_new indicates first-time registration.
|
||||
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
|
||||
// Check if this OAuth account already exists
|
||||
// Use a transaction with FOR UPDATE to prevent TOCTOU race conditions
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Check if this OAuth account already exists (with row lock)
|
||||
let existing: Option<OauthAccount> = sqlx::query_as(
|
||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
||||
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(oa) = existing {
|
||||
let user: User = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(oa.user_id)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok((user, false));
|
||||
}
|
||||
|
||||
@@ -43,12 +47,10 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
|
||||
.clone()
|
||||
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
|
||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at",
|
||||
)
|
||||
.bind(&profile.email)
|
||||
.bind(&display_name)
|
||||
@@ -74,6 +76,53 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
|
||||
Ok((user, true))
|
||||
}
|
||||
|
||||
/// Re-encrypt all of a user's secrets from `old_key` to `new_key` and update the key metadata.
|
||||
///
|
||||
/// Runs entirely inside a single database transaction: if any secret fails to re-encrypt
|
||||
/// the whole operation is rolled back, leaving the database unchanged.
|
||||
pub async fn change_user_key(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
old_key: &[u8; 32],
|
||||
new_key: &[u8; 32],
|
||||
new_salt: &[u8],
|
||||
new_key_check: &[u8],
|
||||
new_key_params: &Value,
|
||||
) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let secrets: Vec<(uuid::Uuid, Vec<u8>)> =
|
||||
sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for (id, encrypted) in &secrets {
|
||||
let plaintext = crate::crypto::decrypt(old_key, encrypted)?;
|
||||
let new_encrypted = crate::crypto::encrypt(new_key, &plaintext)?;
|
||||
sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(&new_encrypted)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, \
|
||||
key_version = key_version + 1, updated_at = NOW() \
|
||||
WHERE id = $4",
|
||||
)
|
||||
.bind(new_salt)
|
||||
.bind(new_key_check)
|
||||
.bind(new_key_params)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
|
||||
pub async fn update_user_key_setup(
|
||||
pool: &PgPool,
|
||||
@@ -98,7 +147,7 @@ pub async fn update_user_key_setup(
|
||||
/// Fetch a user by ID.
|
||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
@@ -125,13 +174,16 @@ pub async fn bind_oauth_account(
|
||||
user_id: Uuid,
|
||||
profile: OAuthProfile,
|
||||
) -> Result<OauthAccount> {
|
||||
// Check if this provider_id is already linked to someone else
|
||||
// Use a transaction with FOR UPDATE to prevent TOCTOU race conditions
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Check if this provider_id is already linked to someone else (with row lock)
|
||||
let conflict: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some((existing_user_id,)) = conflict {
|
||||
@@ -148,11 +200,11 @@ pub async fn bind_oauth_account(
|
||||
}
|
||||
|
||||
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
|
||||
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&profile.provider)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing_provider_for_user.is_some() {
|
||||
@@ -174,9 +226,10 @@ pub async fn bind_oauth_account(
|
||||
.bind(&profile.email)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.avatar_url)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -194,10 +247,14 @@ pub async fn unbind_oauth_account(
|
||||
);
|
||||
}
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let locked_accounts: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT provider FROM oauth_accounts WHERE user_id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let count = locked_accounts.len();
|
||||
|
||||
if count <= 1 {
|
||||
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
|
||||
@@ -206,8 +263,87 @@ pub async fn unbind_oauth_account(
|
||||
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
|
||||
.bind(user_id)
|
||||
.bind(provider)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn maybe_test_pool() -> Option<PgPool> {
|
||||
let database_url = match std::env::var("SECRETS_DATABASE_URL") {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
eprintln!("skip user service tests: SECRETS_DATABASE_URL not set");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pool = match sqlx::PgPool::connect(&database_url).await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
eprintln!("skip user service tests: cannot connect to database: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::db::migrate(&pool).await {
|
||||
eprintln!("skip user service tests: migrate failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn cleanup_user_rows(pool: &PgPool, user_id: Uuid) -> Result<()> {
|
||||
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unbind_oauth_account_removes_only_requested_provider() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let user_id = Uuid::from_u128(rand::random());
|
||||
|
||||
cleanup_user_rows(&pool, user_id).await?;
|
||||
|
||||
sqlx::query("INSERT INTO users (id, name) VALUES ($1, '')")
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
||||
VALUES ($1, 'google', $2, NULL, NULL, NULL), \
|
||||
($1, 'github', $3, NULL, NULL, NULL)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(format!("google-{user_id}"))
|
||||
.bind(format!("github-{user_id}"))
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
unbind_oauth_account(&pool, user_id, "github", Some("google")).await?;
|
||||
|
||||
let remaining: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT provider FROM oauth_accounts WHERE user_id = $1 ORDER BY provider",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(remaining, vec![("google".to_string(),)]);
|
||||
|
||||
cleanup_user_rows(&pool, user_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
27
crates/secrets-core/src/service/util.rs
Normal file
27
crates/secrets-core/src/service/util.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Returns a WHERE condition fragment for user scope and advances `idx` if `user_id` is Some.
|
||||
///
|
||||
/// - `Some(uid)` → `"user_id = $N"` with idx incremented.
|
||||
/// - `None` → `"user_id IS NULL"` with idx unchanged.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut idx = 1i32;
|
||||
/// let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
/// // idx is now 2 if user_id is Some, still 1 if None
|
||||
/// let sql = format!("SELECT ... FROM entries WHERE {user_cond} AND name = ${idx}");
|
||||
/// let mut q = sqlx::query_as::<_, Row>(&sql);
|
||||
/// if let Some(uid) = user_id { q = q.bind(uid); }
|
||||
/// q = q.bind(name);
|
||||
/// ```
|
||||
pub fn user_scope_condition(user_id: Option<Uuid>, idx: &mut i32) -> String {
|
||||
if user_id.is_some() {
|
||||
let s = format!("user_id = ${}", *idx);
|
||||
*idx += 1;
|
||||
s
|
||||
} else {
|
||||
"user_id IS NULL".to_string()
|
||||
}
|
||||
}
|
||||
4
crates/secrets-core/src/taxonomy.rs
Normal file
4
crates/secrets-core/src/taxonomy.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
/// Canonical secret type options for UI dropdowns.
|
||||
pub const SECRET_TYPE_OPTIONS: &[&str] = &[
|
||||
"text", "password", "token", "api-key", "ssh-key", "url", "phone", "id-card",
|
||||
];
|
||||
24
crates/secrets-mcp-local/Cargo.toml
Normal file
24
crates/secrets-mcp-local/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "secrets-mcp-local"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "secrets-mcp-local"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum = "0.8"
|
||||
dotenvy.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
secrets-core = { path = "../secrets-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
url = "2"
|
||||
uuid.workspace = true
|
||||
212
crates/secrets-mcp-local/src/bind.rs
Normal file
212
crates/secrets-mcp-local/src/bind.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cache::{BoundState, PendingBindState};
|
||||
use crate::server::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BindExchangeBody {
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
}
|
||||
|
||||
fn bind_exchange_error_message(value: &Value) -> String {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
pub async fn refresh_bound_state(state: &AppState) {
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
guard.bound.as_ref().map(|bound| bound.api_key.clone())
|
||||
};
|
||||
let Some(api_key) = api_key else {
|
||||
return;
|
||||
};
|
||||
if let Ok(refreshed) = state.remote.bind_refresh(&api_key).await {
|
||||
let mut guard = state.cache.write().await;
|
||||
if matches!(refreshed.status, 401 | 404) {
|
||||
guard.clear_bound_and_unlock();
|
||||
return;
|
||||
}
|
||||
if let Some(refreshed) = refreshed.body {
|
||||
let clear_unlock = if let Some(bound) = guard.bound.as_mut() {
|
||||
let changed = bound.key_version != refreshed.key_version;
|
||||
bound.key_version = refreshed.key_version;
|
||||
bound.key_salt_hex = refreshed.key_salt_hex.clone();
|
||||
bound.key_check_hex = refreshed.key_check_hex.clone();
|
||||
bound.key_params = refreshed.key_params.clone();
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if clear_unlock {
|
||||
guard.clear_unlock_and_exec();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_bind(state: &AppState) -> Result<serde_json::Value, (StatusCode, String)> {
|
||||
let res = state
|
||||
.remote
|
||||
.bind_start()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("bind/start failed: {e}")))?;
|
||||
let started_at = std::time::Instant::now();
|
||||
let expires_at = started_at + std::time::Duration::from_secs(res.expires_in_secs);
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_bound_and_unlock();
|
||||
guard.pending_bind = Some(PendingBindState {
|
||||
bind_id: res.bind_id.clone(),
|
||||
device_code: res.device_code.clone(),
|
||||
approve_url: res.approve_url.clone(),
|
||||
expires_at,
|
||||
started_at,
|
||||
});
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"bind_id": res.bind_id,
|
||||
"device_code": res.device_code,
|
||||
"approve_url": res.approve_url,
|
||||
"expires_in_secs": res.expires_in_secs,
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"next_action": "在浏览器打开 approve_url 完成授权,然后继续轮询 local_bind_exchange",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn exchange_bind(
|
||||
state: &AppState,
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
) -> Result<(StatusCode, serde_json::Value), (StatusCode, String)> {
|
||||
let (bind_id, device_code) = if let (Some(bind_id), Some(device_code)) = (bind_id, device_code)
|
||||
{
|
||||
(bind_id, device_code)
|
||||
} else {
|
||||
let guard = state.cache.read().await;
|
||||
let pending = guard.pending_bind.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing bind session; call /local/bind/start first".to_string(),
|
||||
)
|
||||
})?;
|
||||
(pending.bind_id.clone(), pending.device_code.clone())
|
||||
};
|
||||
|
||||
let result = state
|
||||
.remote
|
||||
.bind_exchange(&bind_id, &device_code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("bind/exchange failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
let status = result.status;
|
||||
let payload = result.body;
|
||||
|
||||
if status == 202 || payload.get("status").and_then(|v| v.as_str()) == Some("pending") {
|
||||
let approve_url = {
|
||||
let guard = state.cache.read().await;
|
||||
guard
|
||||
.pending_bind
|
||||
.as_ref()
|
||||
.filter(|pending| pending.bind_id == bind_id && pending.device_code == device_code)
|
||||
.map(|pending| pending.approve_url.clone())
|
||||
};
|
||||
return Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
json!({
|
||||
"ok": false,
|
||||
"status": "pending",
|
||||
"bind_id": bind_id,
|
||||
"device_code": device_code,
|
||||
"approve_url": approve_url,
|
||||
"next_action": "继续等待远端授权完成,或重新打开 approve_url",
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err((
|
||||
StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
bind_exchange_error_message(&payload),
|
||||
));
|
||||
}
|
||||
let payload: crate::remote::BindExchangeResponse =
|
||||
serde_json::from_value(payload).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("invalid bind/exchange response: {e}"),
|
||||
)
|
||||
})?;
|
||||
let api_key = payload.api_key.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"bind/exchange missing api_key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let user_id = payload.user_id.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"bind/exchange missing user_id".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_pending_bind();
|
||||
guard.bound = Some(BoundState {
|
||||
user_id,
|
||||
api_key,
|
||||
key_salt_hex: payload.key_salt_hex,
|
||||
key_check_hex: payload.key_check_hex,
|
||||
key_params: payload.key_params,
|
||||
key_version: payload.key_version.unwrap_or(0),
|
||||
bound_at: std::time::Instant::now(),
|
||||
});
|
||||
guard.clear_unlock_and_exec();
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"ok": true,
|
||||
"status": "bound",
|
||||
"unlock_url": format!("http://{}/unlock", state.config.bind),
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"next_action": "打开本地 unlock 页面完成 passphrase 解锁",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn bind_start(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let payload = start_bind(&state).await?;
|
||||
Ok((StatusCode::OK, axum::Json(payload)))
|
||||
}
|
||||
|
||||
pub async fn bind_exchange(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(input): axum::Json<BindExchangeBody>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let (status, payload) = exchange_bind(&state, input.bind_id, input.device_code).await?;
|
||||
Ok((status, axum::Json(payload)))
|
||||
}
|
||||
|
||||
pub async fn unbind(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_bound_and_unlock();
|
||||
(StatusCode::OK, axum::Json(json!({ "ok": true })))
|
||||
}
|
||||
234
crates/secrets-mcp-local/src/cache.rs
Normal file
234
crates/secrets-mcp-local/src/cache.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::target::ExecutionTarget;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BoundState {
|
||||
pub user_id: Uuid,
|
||||
pub api_key: String,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: i64,
|
||||
pub bound_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UnlockState {
|
||||
pub encryption_key_hex: String,
|
||||
pub expires_at: Instant,
|
||||
pub last_used_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExecContext {
|
||||
pub target: ExecutionTarget,
|
||||
pub expires_at: Instant,
|
||||
pub last_used_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PendingBindState {
|
||||
pub bind_id: String,
|
||||
pub device_code: String,
|
||||
pub approve_url: String,
|
||||
pub expires_at: Instant,
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GatewayPhase {
|
||||
Bootstrap,
|
||||
PendingUnlock,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GatewayCache {
|
||||
pub pending_bind: Option<PendingBindState>,
|
||||
pub bound: Option<BoundState>,
|
||||
pub unlock: Option<UnlockState>,
|
||||
pub exec_contexts: HashMap<String, ExecContext>,
|
||||
}
|
||||
|
||||
impl GatewayCache {
|
||||
pub fn clear_bound_and_unlock(&mut self) {
|
||||
self.pending_bind = None;
|
||||
self.bound = None;
|
||||
self.unlock = None;
|
||||
self.exec_contexts.clear();
|
||||
}
|
||||
|
||||
pub fn clear_pending_bind(&mut self) {
|
||||
self.pending_bind = None;
|
||||
}
|
||||
|
||||
pub fn clear_unlock_and_exec(&mut self) {
|
||||
self.unlock = None;
|
||||
self.exec_contexts.clear();
|
||||
}
|
||||
|
||||
pub fn phase(&self, now: Instant) -> GatewayPhase {
|
||||
if self.bound.is_none() {
|
||||
return GatewayPhase::Bootstrap;
|
||||
}
|
||||
if self
|
||||
.unlock
|
||||
.as_ref()
|
||||
.is_some_and(|unlock| unlock.expires_at > now && !unlock.encryption_key_hex.is_empty())
|
||||
{
|
||||
GatewayPhase::Ready
|
||||
} else {
|
||||
GatewayPhase::PendingUnlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedCache = Arc<RwLock<GatewayCache>>;
|
||||
|
||||
pub fn new_cache() -> SharedCache {
|
||||
Arc::new(RwLock::new(GatewayCache::default()))
|
||||
}
|
||||
|
||||
fn cleanup_expired(cache: &mut GatewayCache, now: Instant) {
|
||||
if cache
|
||||
.pending_bind
|
||||
.as_ref()
|
||||
.is_some_and(|bind| bind.expires_at <= now)
|
||||
{
|
||||
cache.pending_bind = None;
|
||||
}
|
||||
if let Some(unlock) = cache.unlock.as_ref()
|
||||
&& unlock.expires_at <= now
|
||||
{
|
||||
cache.clear_unlock_and_exec();
|
||||
}
|
||||
cache.exec_contexts.retain(|_, ctx| ctx.expires_at > now);
|
||||
if cache.unlock.is_none() {
|
||||
cache.exec_contexts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_cleanup_task(cache: SharedCache) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Instant::now();
|
||||
let mut guard = cache.write().await;
|
||||
cleanup_expired(&mut guard, now);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::target::ResolvedTarget;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_task_clears_expired_unlock() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: None,
|
||||
bound: None,
|
||||
unlock: Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
last_used_at: Instant::now(),
|
||||
}),
|
||||
exec_contexts: HashMap::new(),
|
||||
};
|
||||
cleanup_expired(&mut cache, Instant::now());
|
||||
assert!(cache.unlock.is_none());
|
||||
assert!(cache.exec_contexts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_unlock_and_exec_drops_entry_contexts() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: None,
|
||||
bound: None,
|
||||
unlock: Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(30),
|
||||
last_used_at: Instant::now(),
|
||||
}),
|
||||
exec_contexts: HashMap::from([(
|
||||
"entry-1".to_string(),
|
||||
ExecContext {
|
||||
target: ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([(
|
||||
"TARGET_API_KEY".to_string(),
|
||||
"sk_test".to_string(),
|
||||
)]),
|
||||
},
|
||||
expires_at: Instant::now() + Duration::from_secs(30),
|
||||
last_used_at: Instant::now(),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
cache.clear_unlock_and_exec();
|
||||
assert!(cache.unlock.is_none());
|
||||
assert!(cache.exec_contexts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_drops_expired_pending_bind() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: Some(PendingBindState {
|
||||
bind_id: "bind-1".to_string(),
|
||||
device_code: "device-1".to_string(),
|
||||
approve_url: "http://example.com/approve".to_string(),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
started_at: Instant::now() - Duration::from_secs(30),
|
||||
}),
|
||||
bound: None,
|
||||
unlock: None,
|
||||
exec_contexts: HashMap::new(),
|
||||
};
|
||||
cleanup_expired(&mut cache, Instant::now());
|
||||
assert!(cache.pending_bind.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_transitions_match_bound_and_unlock() {
|
||||
let now = Instant::now();
|
||||
let mut cache = GatewayCache::default();
|
||||
assert_eq!(cache.phase(now), GatewayPhase::Bootstrap);
|
||||
|
||||
cache.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: now,
|
||||
});
|
||||
assert_eq!(cache.phase(now), GatewayPhase::PendingUnlock);
|
||||
|
||||
cache.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: now + Duration::from_secs(60),
|
||||
last_used_at: now,
|
||||
});
|
||||
assert_eq!(cache.phase(now), GatewayPhase::Ready);
|
||||
}
|
||||
}
|
||||
46
crates/secrets-mcp-local/src/config.rs
Normal file
46
crates/secrets-mcp-local/src/config.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_BIND: &str = "127.0.0.1:9316";
|
||||
const DEFAULT_UNLOCK_TTL_SECS: u64 = 3600;
|
||||
const DEFAULT_EXEC_CONTEXT_TTL_SECS: u64 = 3600;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalConfig {
|
||||
pub bind: SocketAddr,
|
||||
pub remote_base_url: Url,
|
||||
pub default_unlock_ttl: Duration,
|
||||
pub default_exec_context_ttl: Duration,
|
||||
}
|
||||
|
||||
fn load_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result<LocalConfig> {
|
||||
let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string());
|
||||
let bind: SocketAddr = bind
|
||||
.parse()
|
||||
.with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?;
|
||||
|
||||
let remote_base_url: Url = load_env("SECRETS_REMOTE_BASE_URL")
|
||||
.context("SECRETS_REMOTE_BASE_URL is required")?
|
||||
.parse()
|
||||
.context("invalid SECRETS_REMOTE_BASE_URL")?;
|
||||
|
||||
let unlock_ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_UNLOCK_TTL_SECS);
|
||||
let exec_context_ttl_secs: u64 = load_env("SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_EXEC_CONTEXT_TTL_SECS);
|
||||
|
||||
Ok(LocalConfig {
|
||||
bind,
|
||||
remote_base_url,
|
||||
default_unlock_ttl: Duration::from_secs(unlock_ttl_secs.clamp(60, 86400 * 7)),
|
||||
default_exec_context_ttl: Duration::from_secs(exec_context_ttl_secs.clamp(60, 86400 * 7)),
|
||||
})
|
||||
}
|
||||
200
crates/secrets-mcp-local/src/exec.rs
Normal file
200
crates/secrets-mcp-local/src/exec.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::target::{ExecutionTarget, ResolvedTarget};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TargetExecInput {
|
||||
pub target_ref: Option<String>,
|
||||
pub target: Option<crate::target::TargetSnapshot>,
|
||||
pub command: String,
|
||||
pub timeout_secs: Option<u64>,
|
||||
pub working_dir: Option<String>,
|
||||
pub env_overrides: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ExecResult {
|
||||
pub resolved_target: ResolvedTarget,
|
||||
pub resolved_env_keys: Vec<String>,
|
||||
pub command: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub timed_out: bool,
|
||||
pub duration_ms: u128,
|
||||
pub stdout_truncated: bool,
|
||||
pub stderr_truncated: bool,
|
||||
}
|
||||
|
||||
fn truncate_output(text: String) -> (String, bool) {
|
||||
if text.chars().count() <= MAX_OUTPUT_CHARS {
|
||||
return (text, false);
|
||||
}
|
||||
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
|
||||
(truncated, true)
|
||||
}
|
||||
|
||||
fn stringify_env_override(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_env_overrides(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
overrides: Option<&Map<String, Value>>,
|
||||
) -> Result<()> {
|
||||
let Some(overrides) = overrides else {
|
||||
return Ok(());
|
||||
};
|
||||
for (key, value) in overrides {
|
||||
if key.is_empty() || key.contains('=') {
|
||||
return Err(anyhow!("invalid env override key: {key}"));
|
||||
}
|
||||
if key.starts_with("TARGET_") {
|
||||
return Err(anyhow!(
|
||||
"env override `{key}` cannot override reserved TARGET_* variables"
|
||||
));
|
||||
}
|
||||
if let Some(value) = stringify_env_override(value) {
|
||||
env.insert(key.clone(), value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_command(
|
||||
input: &TargetExecInput,
|
||||
target: &ExecutionTarget,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ExecResult> {
|
||||
let mut env = target.env.clone();
|
||||
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
.arg("-lc")
|
||||
.arg(&input.command)
|
||||
.kill_on_drop(true)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
for (key, value) in &env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn command: {}", input.command))?;
|
||||
|
||||
let timed = tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs.clamp(1, 86400)),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (exit_code, stdout, stderr, timed_out) = match timed {
|
||||
Ok(output) => {
|
||||
let output = output.context("failed waiting for command output")?;
|
||||
(
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(_) => (None, String::new(), "command timed out".to_string(), true),
|
||||
};
|
||||
|
||||
let (stdout, stdout_truncated) = truncate_output(stdout);
|
||||
let (stderr, stderr_truncated) = truncate_output(stderr);
|
||||
|
||||
Ok(ExecResult {
|
||||
resolved_target: target.resolved.clone(),
|
||||
resolved_env_keys: target.resolved_env_keys(),
|
||||
command: input.command.clone(),
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
timed_out,
|
||||
duration_ms: started.elapsed().as_millis(),
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::target::ExecutionTarget;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_command_injects_target_env() {
|
||||
let target = ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([
|
||||
("TARGET_HOST".to_string(), "47.238.146.244".to_string()),
|
||||
("TARGET_API_KEY".to_string(), "sk_test_123".to_string()),
|
||||
]),
|
||||
};
|
||||
let input = TargetExecInput {
|
||||
target_ref: Some("entry-1".to_string()),
|
||||
target: None,
|
||||
command: "printf '%s|%s' \"$TARGET_HOST\" \"$TARGET_API_KEY\"".to_string(),
|
||||
timeout_secs: Some(5),
|
||||
working_dir: None,
|
||||
env_overrides: None,
|
||||
};
|
||||
let result = execute_command(&input, &target, 5).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert_eq!(result.stdout, "47.238.146.244|sk_test_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_command_rejects_reserved_target_override() {
|
||||
let target = ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([("TARGET_HOST".to_string(), "47.238.146.244".to_string())]),
|
||||
};
|
||||
let input = TargetExecInput {
|
||||
target_ref: Some("entry-1".to_string()),
|
||||
target: None,
|
||||
command: "echo test".to_string(),
|
||||
timeout_secs: Some(5),
|
||||
working_dir: None,
|
||||
env_overrides: Some(serde_json::from_value(json!({"TARGET_HOST":"override"})).unwrap()),
|
||||
};
|
||||
let err = execute_command(&input, &target, 5).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("cannot override reserved TARGET_* variables")
|
||||
);
|
||||
}
|
||||
}
|
||||
55
crates/secrets-mcp-local/src/main.rs
Normal file
55
crates/secrets-mcp-local/src/main.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
mod bind;
|
||||
mod cache;
|
||||
mod config;
|
||||
mod exec;
|
||||
mod mcp;
|
||||
mod remote;
|
||||
mod server;
|
||||
mod target;
|
||||
mod unlock;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "secrets_mcp_local=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = config::load_config()?;
|
||||
let remote = std::sync::Arc::new(remote::RemoteClient::new(config.remote_base_url.clone())?);
|
||||
let cache = cache::new_cache();
|
||||
let cleanup = cache::spawn_cleanup_task(cache.clone());
|
||||
|
||||
let app_state = server::AppState {
|
||||
config: config.clone(),
|
||||
cache,
|
||||
remote,
|
||||
};
|
||||
let app = server::router(app_state);
|
||||
|
||||
tracing::info!(
|
||||
bind = %config.bind,
|
||||
remote = %config.remote_base_url,
|
||||
"secrets-mcp-local service started"
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(config.bind)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind {}", config.bind))?;
|
||||
|
||||
let result = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("server error");
|
||||
cleanup.abort();
|
||||
result
|
||||
}
|
||||
828
crates/secrets-mcp-local/src/mcp.rs
Normal file
828
crates/secrets-mcp-local/src/mcp.rs
Normal file
@@ -0,0 +1,828 @@
|
||||
use std::convert::Infallible;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::bind::{exchange_bind, start_bind};
|
||||
use crate::cache::{ExecContext, GatewayPhase};
|
||||
use crate::exec::{TargetExecInput, execute_command};
|
||||
use crate::server::AppState;
|
||||
use crate::target::{TargetSnapshot, build_execution_target};
|
||||
use crate::unlock::status_payload;
|
||||
|
||||
const LOCAL_EXEC_TOOL: &str = "target_exec";
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct BindExchangeArgs {
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
}
|
||||
|
||||
fn json_response(status: StatusCode, value: Value) -> Response {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(Body::from(value.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_success_response(id: Value, value: Value) -> Response {
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": pretty,
|
||||
}
|
||||
],
|
||||
"isError": false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": message.into(),
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn empty_notification_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::ACCEPTED)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn method_not_found(id: Value, method: &str) -> Response {
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("method `{method}` not supported by secrets-mcp-local"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn invalid_request_response(message: impl Into<String>) -> Response {
|
||||
json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": message.into(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "local_status",
|
||||
"description": "Read the local gateway readiness state, onboarding URL, unlock URL, and any pending approval session.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local MCP Status" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_unlock_status",
|
||||
"description": "Return whether the local gateway is waiting for passphrase unlock or already ready.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local Unlock Status" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_onboarding_info",
|
||||
"description": "Return the local onboarding page URL, MCP URL, and current next-step guidance for the user.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local Onboarding Info" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn bind_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "local_bind_start",
|
||||
"description": "Start a new remote authorization session and return the approve_url that the user should open in a browser.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Start Local MCP Binding" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_bind_exchange",
|
||||
"description": "Poll the current bind session. When the user has approved in the browser, this moves the gateway into pendingUnlock and returns the local unlock URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bind_id": { "type": ["string", "null"] },
|
||||
"device_code": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Poll Binding State" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn ready_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "secrets_find",
|
||||
"description": "Find entries in the secrets store and return target snapshots suitable for target_exec.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"metadata_query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"name_query": { "type": ["string", "null"] },
|
||||
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
|
||||
"limit": { "type": ["integer", "null"] },
|
||||
"offset": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Find Secrets" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_search",
|
||||
"description": "Search entries with optional summary mode. Returns metadata and secret field names, not secret values.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"metadata_query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"name_query": { "type": ["string", "null"] },
|
||||
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
|
||||
"summary": { "type": ["boolean", "null"] },
|
||||
"sort": { "type": ["string", "null"] },
|
||||
"limit": { "type": ["integer", "null"] },
|
||||
"offset": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Search Secrets" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_history",
|
||||
"description": "View change history for an entry by id or by name/folder.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"limit": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "View Secret History" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_overview",
|
||||
"description": "Get counts of entries per folder and per type.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Secrets Overview" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_delete",
|
||||
"description": "Preview deletions only. dry_run must be true.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"dry_run": { "type": ["boolean", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Delete Secret Entry Preview", "destructiveHint": true }
|
||||
}),
|
||||
json!({
|
||||
"name": LOCAL_EXEC_TOOL,
|
||||
"description": "Execute a standard local command against a resolved secrets target. The local gateway injects target metadata and secret values as environment variables without exposing raw secret values to the AI.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_ref": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Target entry id from secrets_find/secrets_search. Required on first use; later calls may reuse the cached execution context for the same entry id."
|
||||
},
|
||||
"target": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Optional target snapshot copied from secrets_find/secrets_search. Required on first use when the local gateway has not cached this entry id yet."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Standard shell command to execute locally, such as ssh/curl/docker/http."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Execution timeout in seconds."
|
||||
},
|
||||
"working_dir": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional working directory for the command."
|
||||
},
|
||||
"env_overrides": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Optional extra environment variables. Reserved TARGET_* names cannot be overridden."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
},
|
||||
"annotations": { "title": "Execute Against Target" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn tools_for_phase(phase: GatewayPhase) -> Vec<Value> {
|
||||
let mut tools = status_tool_definitions();
|
||||
if phase != GatewayPhase::Ready {
|
||||
tools.extend(bind_tool_definitions());
|
||||
}
|
||||
if phase == GatewayPhase::Ready {
|
||||
tools.extend(ready_tool_definitions());
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
async fn current_phase_and_status(state: &AppState) -> (GatewayPhase, Value) {
|
||||
let payload = status_payload(state).await;
|
||||
let phase = payload
|
||||
.get("state")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
.unwrap_or(GatewayPhase::Bootstrap);
|
||||
(phase, payload)
|
||||
}
|
||||
|
||||
fn instructions_for_phase(phase: GatewayPhase) -> &'static str {
|
||||
match phase {
|
||||
GatewayPhase::Bootstrap => {
|
||||
"Use local_status and local_bind_start first. The user must open the approve_url in a browser before the local gateway can continue."
|
||||
}
|
||||
GatewayPhase::PendingUnlock => {
|
||||
"Remote authorization is complete. Ask the user to open the local unlock page and finish passphrase unlock before calling business tools."
|
||||
}
|
||||
GatewayPhase::Ready => {
|
||||
"The local gateway is ready. Use secrets_find/secrets_search for discovery and target_exec for delegated command execution against decrypted targets."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_response(id: Value, phase: GatewayPhase) -> Response {
|
||||
let session_id = format!(
|
||||
"local-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "secrets-mcp-local",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"title": "Secrets MCP Local"
|
||||
},
|
||||
"instructions": instructions_for_phase(phase),
|
||||
}
|
||||
});
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.header("mcp-session-id", session_id)
|
||||
.body(Body::from(response.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn resolve_target_context(
|
||||
state: &AppState,
|
||||
api_key: &str,
|
||||
unlock_key: &str,
|
||||
unlock_expires_at: Instant,
|
||||
input: &TargetExecInput,
|
||||
) -> anyhow::Result<crate::target::ExecutionTarget> {
|
||||
let target_ref = input
|
||||
.target_ref
|
||||
.clone()
|
||||
.or_else(|| input.target.as_ref().map(|t| t.id.clone()))
|
||||
.ok_or_else(|| anyhow::anyhow!("target_ref is required"))?;
|
||||
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
if let Some(ctx) = guard.exec_contexts.get_mut(&target_ref)
|
||||
&& ctx.expires_at > Instant::now()
|
||||
{
|
||||
ctx.last_used_at = Instant::now();
|
||||
return Ok(ctx.target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot: TargetSnapshot = input.target.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"target details required on first use for entry `{target_ref}`; pass the matching secrets_find/search result as `target`"
|
||||
)
|
||||
})?;
|
||||
if snapshot.id != target_ref {
|
||||
return Err(anyhow::anyhow!(
|
||||
"target_ref `{target_ref}` does not match target.id `{}`",
|
||||
snapshot.id
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = state
|
||||
.remote
|
||||
.get_entry_secrets_by_id(api_key, unlock_key, &target_ref)
|
||||
.await?;
|
||||
let target = build_execution_target(&snapshot, &secrets)?;
|
||||
let expires_at = std::cmp::min(
|
||||
Instant::now() + state.config.default_exec_context_ttl,
|
||||
unlock_expires_at,
|
||||
);
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.exec_contexts.insert(
|
||||
target_ref,
|
||||
ExecContext {
|
||||
target: target.clone(),
|
||||
expires_at,
|
||||
last_used_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn handle_target_exec(state: &AppState, id: Value, args: Option<Value>) -> Response {
|
||||
let input: TargetExecInput = match args {
|
||||
Some(value) => match serde_json::from_value(value) {
|
||||
Ok(input) => input,
|
||||
Err(err) => {
|
||||
return tool_error_response(id, format!("invalid `{LOCAL_EXEC_TOOL}` args: {err}"));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return tool_error_response(id, format!("`{LOCAL_EXEC_TOOL}` arguments are required"));
|
||||
}
|
||||
};
|
||||
if input.command.trim().is_empty() {
|
||||
return tool_error_response(id, "command is required");
|
||||
}
|
||||
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
match guard.bound.as_ref() {
|
||||
Some(bound) => bound.api_key.clone(),
|
||||
None => {
|
||||
return tool_error_response(
|
||||
id,
|
||||
"local MCP is not bound; call local_bind_start first",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let (unlock_key, unlock_expires_at) = {
|
||||
let mut guard = state.cache.write().await;
|
||||
match guard.unlock.as_mut() {
|
||||
Some(unlock) if unlock.expires_at > Instant::now() => {
|
||||
unlock.last_used_at = Instant::now();
|
||||
(unlock.encryption_key_hex.clone(), unlock.expires_at)
|
||||
}
|
||||
_ => {
|
||||
guard.clear_unlock_and_exec();
|
||||
return tool_error_response(
|
||||
id,
|
||||
"local MCP is not unlocked; ask the user to open the local unlock page first",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let target =
|
||||
match resolve_target_context(state, &api_key, &unlock_key, unlock_expires_at, &input).await
|
||||
{
|
||||
Ok(target) => target,
|
||||
Err(err) => return tool_error_response(id, format!("failed resolving target: {err}")),
|
||||
};
|
||||
let timeout_secs = input.timeout_secs.unwrap_or(120).clamp(1, 3600);
|
||||
let result = match execute_command(&input, &target, timeout_secs).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => return tool_error_response(id, format!("execution failed: {err}")),
|
||||
};
|
||||
tool_success_response(
|
||||
id,
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_bootstrap_tool(
|
||||
state: &AppState,
|
||||
tool_name: &str,
|
||||
id: Value,
|
||||
args: Option<Value>,
|
||||
) -> Response {
|
||||
match tool_name {
|
||||
"local_status" | "local_unlock_status" | "local_onboarding_info" => {
|
||||
tool_success_response(id, status_payload(state).await)
|
||||
}
|
||||
"local_bind_start" => match start_bind(state).await {
|
||||
Ok(payload) => tool_success_response(id, payload),
|
||||
Err((_status, message)) => tool_error_response(id, message),
|
||||
},
|
||||
"local_bind_exchange" => {
|
||||
let parsed = match args {
|
||||
Some(value) => match serde_json::from_value::<BindExchangeArgs>(value) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
return tool_error_response(
|
||||
id,
|
||||
format!("invalid local_bind_exchange args: {err}"),
|
||||
);
|
||||
}
|
||||
},
|
||||
None => BindExchangeArgs::default(),
|
||||
};
|
||||
match exchange_bind(state, parsed.bind_id, parsed.device_code).await {
|
||||
Ok((_status, payload)) => tool_success_response(id, payload),
|
||||
Err((_status, message)) => tool_error_response(id, message),
|
||||
}
|
||||
}
|
||||
_ => tool_error_response(id, format!("unknown bootstrap tool `{tool_name}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bootstrap_tool_allowed_in_phase(tool_name: &str, phase: GatewayPhase) -> bool {
|
||||
is_status_tool(tool_name) || (phase != GatewayPhase::Ready && is_bind_tool(tool_name))
|
||||
}
|
||||
|
||||
fn is_status_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"local_status" | "local_unlock_status" | "local_onboarding_info"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_bind_tool(tool_name: &str) -> bool {
|
||||
matches!(tool_name, "local_bind_start" | "local_bind_exchange")
|
||||
}
|
||||
|
||||
fn is_bootstrap_tool(tool_name: &str) -> bool {
|
||||
is_status_tool(tool_name) || is_bind_tool(tool_name)
|
||||
}
|
||||
|
||||
fn is_ready_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"secrets_find"
|
||||
| "secrets_search"
|
||||
| "secrets_history"
|
||||
| "secrets_overview"
|
||||
| "secrets_delete"
|
||||
| LOCAL_EXEC_TOOL
|
||||
)
|
||||
}
|
||||
|
||||
fn not_ready_message(status: &Value) -> String {
|
||||
let onboarding_url = status
|
||||
.get("onboarding_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/");
|
||||
let state_name = status
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("bootstrap");
|
||||
format!(
|
||||
"local MCP is not ready (state: {state_name}). Use local_status/local_bind_start first and ask the user to complete onboarding at {onboarding_url}"
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_ready_tool(
|
||||
state: &AppState,
|
||||
tool_name: &str,
|
||||
id: Value,
|
||||
args: Option<Value>,
|
||||
) -> Response {
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
match guard.bound.as_ref() {
|
||||
Some(bound) => bound.api_key.clone(),
|
||||
None => return tool_error_response(id, "local MCP is not bound"),
|
||||
}
|
||||
};
|
||||
let args_value = args.unwrap_or_else(|| json!({}));
|
||||
let result = match tool_name {
|
||||
"secrets_find" => state.remote.entries_find(&api_key, &args_value).await,
|
||||
"secrets_search" => state.remote.entries_search(&api_key, &args_value).await,
|
||||
"secrets_history" => state.remote.entry_history(&api_key, &args_value).await,
|
||||
"secrets_overview" => state.remote.entries_overview(&api_key).await,
|
||||
"secrets_delete" => {
|
||||
if args_value.get("dry_run").and_then(|value| value.as_bool()) != Some(true) {
|
||||
return tool_error_response(
|
||||
id,
|
||||
"secrets_delete is exposed in local mode only for dry_run=true previews",
|
||||
);
|
||||
}
|
||||
state.remote.delete_preview(&api_key, &args_value).await
|
||||
}
|
||||
LOCAL_EXEC_TOOL => return handle_target_exec(state, id, Some(args_value)).await,
|
||||
_ => return tool_error_response(id, format!("unknown ready tool `{tool_name}`")),
|
||||
};
|
||||
match result {
|
||||
Ok(value) => tool_success_response(id, value),
|
||||
Err(err) => tool_error_response(id, err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_mcp(State(state): State<AppState>, body: Body) -> Result<Response, Infallible> {
|
||||
let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return Ok(invalid_request_response("invalid request body")),
|
||||
};
|
||||
let request: Value = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
return Ok(invalid_request_response(format!(
|
||||
"invalid json body: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let method = request
|
||||
.get("method")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let id = request.get("id").cloned().unwrap_or(json!(null));
|
||||
let (phase, status) = current_phase_and_status(&state).await;
|
||||
|
||||
let response = match method {
|
||||
"initialize" => initialize_response(id, phase),
|
||||
"notifications/initialized" => empty_notification_response(),
|
||||
"tools/list" => jsonrpc_result_response(id, json!({ "tools": tools_for_phase(phase) })),
|
||||
"tools/call" => {
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let tool_name = params
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let args = params.get("arguments").cloned();
|
||||
if is_bootstrap_tool(tool_name) {
|
||||
if !bootstrap_tool_allowed_in_phase(tool_name, phase) {
|
||||
tool_error_response(
|
||||
id,
|
||||
"local MCP is already ready; binding tools are disabled until you explicitly unbind",
|
||||
)
|
||||
} else {
|
||||
handle_bootstrap_tool(&state, tool_name, id, args).await
|
||||
}
|
||||
} else if phase != GatewayPhase::Ready {
|
||||
tool_error_response(id, not_ready_message(&status))
|
||||
} else if is_ready_tool(tool_name) {
|
||||
handle_ready_tool(&state, tool_name, id, args).await
|
||||
} else {
|
||||
tool_error_response(
|
||||
id,
|
||||
format!("tool `{tool_name}` is not exposed by local policy"),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ping" => jsonrpc_result_response(id, json!({})),
|
||||
_ => method_not_found(id, method),
|
||||
};
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cache::{BoundState, UnlockState, new_cache};
|
||||
use crate::config::LocalConfig;
|
||||
use crate::remote::RemoteClient;
|
||||
use crate::server::AppState;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
AppState {
|
||||
config: LocalConfig {
|
||||
bind: "127.0.0.1:9316".parse().unwrap(),
|
||||
remote_base_url: Url::parse("https://example.com").unwrap(),
|
||||
default_unlock_ttl: Duration::from_secs(3600),
|
||||
default_exec_context_ttl: Duration::from_secs(3600),
|
||||
},
|
||||
cache: new_cache(),
|
||||
remote: Arc::new(
|
||||
RemoteClient::new(Url::parse("https://example.com").unwrap()).unwrap(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_phase_hides_ready_tools() {
|
||||
let tools = tools_for_phase(GatewayPhase::Bootstrap);
|
||||
let names: Vec<_> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|value| value.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"local_bind_start"));
|
||||
assert!(!names.contains(&"secrets_find"));
|
||||
assert!(!names.contains(&LOCAL_EXEC_TOOL));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_succeeds_when_unbound() {
|
||||
let response = handle_mcp(
|
||||
State(test_state()),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_returns_bootstrap_tools_when_unbound() {
|
||||
let response = handle_mcp(
|
||||
State(test_state()),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let names: Vec<_> = value["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"local_bind_exchange"));
|
||||
assert!(!names.contains(&"secrets_find"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_in_ready_phase_exposes_business_tools() {
|
||||
let state = test_state();
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: Instant::now(),
|
||||
});
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(600),
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
let response = handle_mcp(
|
||||
State(state),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let names: Vec<_> = value["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"secrets_find"));
|
||||
assert!(names.contains(&LOCAL_EXEC_TOOL));
|
||||
assert!(!names.contains(&"local_bind_start"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_rejects_bind_start_when_ready() {
|
||||
let state = test_state();
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: Instant::now(),
|
||||
});
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(600),
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
let response = handle_mcp(
|
||||
State(state),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "local_bind_start",
|
||||
"arguments": {}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["result"]["isError"], Value::Bool(true));
|
||||
assert!(value.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_error_response_uses_mcp_tool_result_shape() {
|
||||
let response = tool_error_response(json!(9), "boom");
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["id"], json!(9));
|
||||
assert_eq!(value["result"]["isError"], Value::Bool(true));
|
||||
assert_eq!(value["result"]["content"][0]["text"], json!("boom"));
|
||||
assert!(value.get("error").is_none());
|
||||
}
|
||||
}
|
||||
263
crates/secrets-mcp-local/src/remote.rs
Normal file
263
crates/secrets-mcp-local/src/remote.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteClient {
|
||||
pub http_client: reqwest::Client,
|
||||
pub remote_base_url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindStartResponse {
|
||||
pub bind_id: String,
|
||||
pub device_code: String,
|
||||
pub approve_url: String,
|
||||
pub expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindExchangeResponse {
|
||||
pub status: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub api_key: Option<String>,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BindExchangeResult {
|
||||
pub status: u16,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindRefreshResponse {
|
||||
pub user_id: Uuid,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BindRefreshResult {
|
||||
pub status: u16,
|
||||
pub body: Option<BindRefreshResponse>,
|
||||
}
|
||||
|
||||
impl RemoteClient {
|
||||
pub fn new(remote_base_url: Url) -> Result<Self> {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
Ok(Self {
|
||||
http_client,
|
||||
remote_base_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn authed_request(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path(path);
|
||||
let mut req = self
|
||||
.http_client
|
||||
.request(method, url.as_str())
|
||||
.bearer_auth(api_key)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(key) = encryption_key_hex {
|
||||
req = req.header("X-Encryption-Key", key);
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
async fn parse_json_response(
|
||||
&self,
|
||||
res: reqwest::Response,
|
||||
label: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let status = res.status();
|
||||
let bytes = res
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("{label} body read failed"))?;
|
||||
let value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
|
||||
})
|
||||
};
|
||||
if !status.is_success() {
|
||||
let message = value
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| value.to_string());
|
||||
return Err(anyhow!("{label} failed ({}): {message}", status));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn bind_start(&self) -> Result<BindStartResponse> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/start");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.context("bind/start request failed")?;
|
||||
if !res.status().is_success() {
|
||||
return Err(anyhow!("bind/start failed: {}", res.status()));
|
||||
}
|
||||
res.json::<BindStartResponse>()
|
||||
.await
|
||||
.context("invalid bind/start response")
|
||||
}
|
||||
|
||||
pub async fn bind_exchange(
|
||||
&self,
|
||||
bind_id: &str,
|
||||
device_code: &str,
|
||||
) -> Result<BindExchangeResult> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/exchange");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.json(&serde_json::json!({
|
||||
"bind_id": bind_id,
|
||||
"device_code": device_code,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("bind/exchange request failed")?;
|
||||
let status = res.status().as_u16();
|
||||
let bytes = res
|
||||
.bytes()
|
||||
.await
|
||||
.context("bind/exchange body read failed")?;
|
||||
let body = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
|
||||
})
|
||||
};
|
||||
Ok(BindExchangeResult { status, body })
|
||||
}
|
||||
|
||||
pub async fn bind_refresh(&self, api_key: &str) -> Result<BindRefreshResult> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/refresh");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {api_key}"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.context("bind/refresh request failed")?;
|
||||
let status = res.status().as_u16();
|
||||
if !res.status().is_success() {
|
||||
return Ok(BindRefreshResult { status, body: None });
|
||||
}
|
||||
let body = res
|
||||
.json::<BindRefreshResponse>()
|
||||
.await
|
||||
.context("invalid bind/refresh response")?;
|
||||
Ok(BindRefreshResult {
|
||||
status,
|
||||
body: Some(body),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_api_json(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
) -> Result<Value> {
|
||||
let res = self
|
||||
.authed_request(reqwest::Method::POST, path, api_key, encryption_key_hex)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("{path} request failed"))?;
|
||||
self.parse_json_response(res, path).await
|
||||
}
|
||||
|
||||
async fn get_api_json(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let req = self.authed_request(reqwest::Method::GET, path, api_key, encryption_key_hex);
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("{path} request failed"))?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn entries_find(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/find", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entries_search(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/search", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entry_history(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/history", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entries_overview(&self, api_key: &str) -> Result<Value> {
|
||||
let res = self
|
||||
.get_api_json(api_key, None, "/api/local-mcp/entries/overview")
|
||||
.await?;
|
||||
self.parse_json_response(res, "/api/local-mcp/entries/overview")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_preview(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/delete-preview", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_entry_secrets_by_id(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: &str,
|
||||
entry_id: &str,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let path = format!("/api/local-mcp/entries/{entry_id}/secrets");
|
||||
let res = self
|
||||
.get_api_json(api_key, Some(encryption_key_hex), &path)
|
||||
.await?;
|
||||
let value = self.parse_json_response(res, &path).await?;
|
||||
serde_json::from_value::<HashMap<String, Value>>(value)
|
||||
.context("invalid decrypt payload from remote HTTP API")
|
||||
}
|
||||
}
|
||||
157
crates/secrets-mcp-local/src/server.rs
Normal file
157
crates/secrets-mcp-local/src/server.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use crate::cache::SharedCache;
|
||||
use crate::config::LocalConfig;
|
||||
use crate::remote::RemoteClient;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: LocalConfig,
|
||||
pub cache: SharedCache,
|
||||
pub remote: Arc<RemoteClient>,
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>secrets-mcp-local onboarding</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 920px; margin: 24px auto; padding: 0 16px; line-height: 1.5; }}
|
||||
code, pre {{ background: #f6f8fa; border-radius: 6px; }}
|
||||
code {{ padding: 2px 6px; }}
|
||||
pre {{ padding: 12px; overflow-x: auto; }}
|
||||
.card {{ border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; margin: 16px 0; }}
|
||||
.row {{ display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }}
|
||||
button, a.button {{ border: 1px solid #1f2328; background: #1f2328; color: white; padding: 8px 14px; border-radius: 8px; text-decoration: none; cursor: pointer; }}
|
||||
a.secondary, button.secondary {{ background: white; color: #1f2328; }}
|
||||
iframe {{ width: 100%; min-height: 420px; border: 1px solid #d0d7de; border-radius: 12px; }}
|
||||
.muted {{ color: #57606a; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>secrets-mcp-local</h1>
|
||||
<p class="muted">本地 MCP 地址:<code>http://{bind}/mcp</code></p>
|
||||
<p class="muted">远端服务地址:<code>{remote}</code></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>当前状态</h2>
|
||||
<pre id="status">loading...</pre>
|
||||
<div class="row">
|
||||
<button id="start-bind">开始绑定</button>
|
||||
<button id="poll-bind" class="secondary">检查授权结果</button>
|
||||
<a class="button secondary" href="/unlock" target="_blank" rel="noreferrer">打开解锁页</a>
|
||||
<button id="refresh" class="secondary">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>步骤 1:远端授权</h2>
|
||||
<p id="approve-hint" class="muted">点击“开始绑定”后,这里会显示授权地址。</p>
|
||||
<div id="approve-actions" class="row"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>步骤 2:本地解锁</h2>
|
||||
<p class="muted">授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。</p>
|
||||
<iframe id="unlock-frame" src="/unlock"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>接入 Cursor</h2>
|
||||
<p>把 MCP 地址配置为 <code>http://{bind}/mcp</code>。在未就绪时,AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const statusEl = document.getElementById('status');
|
||||
const approveHint = document.getElementById('approve-hint');
|
||||
const approveActions = document.getElementById('approve-actions');
|
||||
const unlockFrame = document.getElementById('unlock-frame');
|
||||
|
||||
function renderApprove(info) {{
|
||||
approveActions.innerHTML = '';
|
||||
if (!info?.approve_url) return;
|
||||
approveHint.textContent = '请先在浏览器完成远端授权,然后回到这里等待自动进入解锁状态。';
|
||||
const link = document.createElement('a');
|
||||
link.href = info.approve_url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
link.className = 'button';
|
||||
link.textContent = '打开远端授权页';
|
||||
approveActions.appendChild(link);
|
||||
}}
|
||||
|
||||
async function refreshStatus() {{
|
||||
const res = await fetch('/local/status');
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
if (data.pending_bind) renderApprove(data.pending_bind);
|
||||
if (data.state === 'ready') {{
|
||||
approveHint.textContent = '本地 MCP 已 ready,可以返回 Cursor 正常使用。';
|
||||
}} else if (data.state === 'pendingUnlock') {{
|
||||
approveHint.textContent = '远端授权已完成,继续在下方完成本地解锁。';
|
||||
}}
|
||||
return data;
|
||||
}}
|
||||
|
||||
async function startBind() {{
|
||||
const res = await fetch('/local/bind/start', {{ method: 'POST' }});
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
renderApprove(data);
|
||||
}}
|
||||
|
||||
async function pollBind() {{
|
||||
const res = await fetch('/local/bind/exchange', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{}})
|
||||
}});
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
await refreshStatus();
|
||||
if (res.ok && data.status === 'bound') {{
|
||||
unlockFrame.src = '/unlock';
|
||||
}}
|
||||
}}
|
||||
|
||||
document.getElementById('start-bind').onclick = startBind;
|
||||
document.getElementById('poll-bind').onclick = pollBind;
|
||||
document.getElementById('refresh').onclick = refreshStatus;
|
||||
window.addEventListener('message', (event) => {{
|
||||
if (event?.data?.type === 'secrets-mcp-local-ready') refreshStatus();
|
||||
}});
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
bind = state.config.bind,
|
||||
remote = state.config.remote_base_url,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/mcp", axum::routing::any(crate::mcp::handle_mcp))
|
||||
.route("/local/bind/start", post(crate::bind::bind_start))
|
||||
.route("/local/bind/exchange", post(crate::bind::bind_exchange))
|
||||
.route("/local/unbind", post(crate::bind::unbind))
|
||||
.route("/unlock", get(crate::unlock::unlock_page))
|
||||
.route(
|
||||
"/local/unlock/complete",
|
||||
post(crate::unlock::unlock_complete),
|
||||
)
|
||||
.route("/local/lock", post(crate::unlock::lock))
|
||||
.route("/local/status", get(crate::unlock::status))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
263
crates/secrets-mcp-local/src/target.rs
Normal file
263
crates/secrets-mcp-local/src/target.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SecretFieldRef {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub secret_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TargetSnapshot {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
pub secret_fields: Vec<SecretFieldRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ResolvedTarget {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutionTarget {
|
||||
pub resolved: ResolvedTarget,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExecutionTarget {
|
||||
pub fn resolved_env_keys(&self) -> Vec<String> {
|
||||
self.env.keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn stringify_value(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_env_key(key: &str) -> String {
|
||||
let mut out = String::with_capacity(key.len());
|
||||
for ch in key.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_uppercase());
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
while out.contains("__") {
|
||||
out = out.replace("__", "_");
|
||||
}
|
||||
out.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = value.filter(|v| !v.is_empty()) {
|
||||
env.entry(key.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| metadata.get(*key))
|
||||
.and_then(stringify_value)
|
||||
}
|
||||
|
||||
fn secret_alias(
|
||||
secrets: &HashMap<String, Value>,
|
||||
secret_types: &HashMap<&str, Option<&str>>,
|
||||
name_match: impl Fn(&str) -> bool,
|
||||
type_match: impl Fn(Option<&str>) -> bool,
|
||||
) -> Option<String> {
|
||||
secrets.iter().find_map(|(name, value)| {
|
||||
let normalized = sanitize_env_key(name);
|
||||
let ty = secret_types.get(name.as_str()).copied().flatten();
|
||||
if name_match(&normalized) || type_match(ty) {
|
||||
stringify_value(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_execution_target(
|
||||
snapshot: &TargetSnapshot,
|
||||
secrets: &HashMap<String, Value>,
|
||||
) -> Result<ExecutionTarget> {
|
||||
if snapshot.id.trim().is_empty() {
|
||||
return Err(anyhow!("target snapshot missing id"));
|
||||
}
|
||||
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
|
||||
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
|
||||
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
|
||||
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
|
||||
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
|
||||
}
|
||||
if let Some(notes) = snapshot.notes.as_ref().filter(|v| !v.is_empty()) {
|
||||
env.insert("TARGET_NOTES".to_string(), notes.clone());
|
||||
}
|
||||
|
||||
for (key, value) in &snapshot.metadata {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_META_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
|
||||
.secret_fields
|
||||
.iter()
|
||||
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
|
||||
.collect();
|
||||
|
||||
for (key, value) in secrets {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_SECRET_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_HOST",
|
||||
metadata_alias(
|
||||
&snapshot.metadata,
|
||||
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_PORT",
|
||||
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_USER",
|
||||
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_BASE_URL",
|
||||
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_API_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_TOKEN",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("TOKEN"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_SSH_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("SSH") || name.ends_with("PEM"),
|
||||
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
|
||||
),
|
||||
);
|
||||
|
||||
Ok(ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: snapshot.id.clone(),
|
||||
folder: snapshot.folder.clone(),
|
||||
name: snapshot.name.clone(),
|
||||
entry_type: snapshot.entry_type.clone(),
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_execution_target_maps_common_aliases() {
|
||||
let snapshot = TargetSnapshot {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "hk_api_hub".to_string(),
|
||||
entry_type: Some("server".to_string()),
|
||||
notes: None,
|
||||
metadata: serde_json::from_value(json!({
|
||||
"public_ip": "47.238.146.244",
|
||||
"username": "ecs-user",
|
||||
"base_url": "https://api.refining.dev"
|
||||
}))
|
||||
.unwrap(),
|
||||
secret_fields: vec![
|
||||
SecretFieldRef {
|
||||
name: "api_key".to_string(),
|
||||
secret_type: None,
|
||||
},
|
||||
SecretFieldRef {
|
||||
name: "hk-20240726.pem".to_string(),
|
||||
secret_type: Some("ssh-key".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let secrets = HashMap::from([
|
||||
("api_key".to_string(), json!("sk_test_123")),
|
||||
(
|
||||
"hk-20240726.pem".to_string(),
|
||||
json!("-----BEGIN PRIVATE KEY-----"),
|
||||
),
|
||||
]);
|
||||
|
||||
let target = build_execution_target(&snapshot, &secrets).unwrap();
|
||||
assert_eq!(target.env.get("TARGET_HOST").unwrap(), "47.238.146.244");
|
||||
assert_eq!(target.env.get("TARGET_USER").unwrap(), "ecs-user");
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_BASE_URL").unwrap(),
|
||||
"https://api.refining.dev"
|
||||
);
|
||||
assert_eq!(target.env.get("TARGET_API_KEY").unwrap(), "sk_test_123");
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_SSH_KEY").unwrap(),
|
||||
"-----BEGIN PRIVATE KEY-----"
|
||||
);
|
||||
}
|
||||
}
|
||||
265
crates/secrets-mcp-local/src/unlock.rs
Normal file
265
crates/secrets-mcp-local/src/unlock.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use secrets_core::crypto::{decrypt, extract_key_from_hex, hex};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::bind::refresh_bound_state;
|
||||
use crate::cache::UnlockState;
|
||||
use crate::server::AppState;
|
||||
|
||||
const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-mcp-key-check";
|
||||
|
||||
fn verify_key_check_hex(key_hex: &str, key_check_hex: &str) -> Result<(), (StatusCode, String)> {
|
||||
let key_check = hex::decode_hex(key_check_hex).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid key_check hex: {e}"),
|
||||
)
|
||||
})?;
|
||||
let user_key = extract_key_from_hex(key_hex).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid encryption key: {e}"),
|
||||
)
|
||||
})?;
|
||||
let plaintext = decrypt(&user_key, &key_check)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()))?;
|
||||
if plaintext != KEY_CHECK_PLAINTEXT {
|
||||
return Err((StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UnlockCompleteBody {
|
||||
encryption_key: String,
|
||||
ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn unlock_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||
refresh_bound_state(&state).await;
|
||||
let bound = {
|
||||
let guard = state.cache.read().await;
|
||||
guard.bound.clone()
|
||||
};
|
||||
let Some(mut bound) = bound else {
|
||||
return Html(
|
||||
"<h1>Not bound</h1><p>Run /local/bind/start and complete approve first.</p>"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
{
|
||||
let guard = state.cache.read().await;
|
||||
if let Some(updated) = guard.bound.clone() {
|
||||
bound = updated;
|
||||
}
|
||||
}
|
||||
let key_salt_hex = bound.key_salt_hex.as_deref().unwrap_or("");
|
||||
let key_check_hex = bound.key_check_hex.as_deref().unwrap_or("");
|
||||
let iterations = bound
|
||||
.key_params
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("iterations"))
|
||||
.and_then(|n| n.as_u64())
|
||||
.unwrap_or(600_000);
|
||||
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>Local MCP Unlock</title></head>
|
||||
<body>
|
||||
<h1>解锁本地 MCP</h1>
|
||||
<p>用户:<code>{user_id}</code></p>
|
||||
<label>Passphrase: <input id="pp" type="password" autocomplete="off"/></label>
|
||||
<label>TTL(sec): <input id="ttl" type="number" value="{ttl}" min="60" max="604800"/></label>
|
||||
<button id="go">Derive and Unlock</button>
|
||||
<pre id="out"></pre>
|
||||
<script>
|
||||
const SALT_HEX = "{salt}";
|
||||
const KEY_CHECK_HEX = "{key_check}";
|
||||
const ITER = {iter};
|
||||
function notifyParentReady() {{
|
||||
try {{
|
||||
window.parent?.postMessage({{type:'secrets-mcp-local-ready'}}, '*');
|
||||
}} catch (_err) {{}}
|
||||
}}
|
||||
function hexToBytes(hex) {{
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i*2,2), 16);
|
||||
return out;
|
||||
}}
|
||||
function bytesToHex(bytes) {{
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
|
||||
}}
|
||||
async function verifyKeyCheck(hexKey) {{
|
||||
const keyBytes = hexToBytes(hexKey);
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, {{name:'AES-GCM'}}, false, ['decrypt']);
|
||||
const payload = hexToBytes(KEY_CHECK_HEX);
|
||||
const nonce = payload.slice(0, 12);
|
||||
const ciphertext = payload.slice(12);
|
||||
try {{
|
||||
const plain = await crypto.subtle.decrypt({{name:'AES-GCM', iv: nonce}}, cryptoKey, ciphertext);
|
||||
return new TextDecoder().decode(plain) === 'secrets-mcp-key-check';
|
||||
}} catch {{
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
document.getElementById('go').onclick = async () => {{
|
||||
const pp = document.getElementById('pp').value;
|
||||
const ttl = Number(document.getElementById('ttl').value || {ttl});
|
||||
const out = document.getElementById('out');
|
||||
if (!SALT_HEX) {{ out.textContent = 'key_salt missing; set passphrase on remote first'; return; }}
|
||||
if (!KEY_CHECK_HEX) {{ out.textContent = 'key_check missing; refresh bind first'; return; }}
|
||||
if (!pp) {{ out.textContent = 'passphrase required'; return; }}
|
||||
out.textContent = 'deriving...';
|
||||
try {{
|
||||
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(pp), {{name:'PBKDF2'}}, false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits({{name:'PBKDF2', salt: hexToBytes(SALT_HEX), iterations: ITER, hash: 'SHA-256'}}, keyMat, 256);
|
||||
const hex = bytesToHex(new Uint8Array(bits));
|
||||
const valid = await verifyKeyCheck(hex);
|
||||
if (!valid) {{ out.textContent = 'wrong passphrase'; return; }}
|
||||
const res = await fetch('/local/unlock/complete', {{
|
||||
method:'POST', headers:{{'content-type':'application/json'}},
|
||||
body: JSON.stringify({{encryption_key: hex, ttl_secs: ttl}})
|
||||
}});
|
||||
const txt = await res.text();
|
||||
out.textContent = txt;
|
||||
if (res.ok) notifyParentReady();
|
||||
}} catch (e) {{
|
||||
out.textContent = String(e);
|
||||
}}
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
user_id = bound.user_id,
|
||||
ttl = state.config.default_unlock_ttl.as_secs(),
|
||||
salt = key_salt_hex,
|
||||
key_check = key_check_hex,
|
||||
iter = iterations
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn unlock_complete(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(input): axum::Json<UnlockCompleteBody>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let key = input.encryption_key.trim();
|
||||
if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"encryption_key must be 64 hex chars".to_string(),
|
||||
));
|
||||
}
|
||||
let ttl = std::time::Duration::from_secs(
|
||||
input
|
||||
.ttl_secs
|
||||
.unwrap_or(state.config.default_unlock_ttl.as_secs())
|
||||
.clamp(60, 86400 * 7),
|
||||
);
|
||||
let mut guard = state.cache.write().await;
|
||||
let Some(bound) = guard.bound.as_ref() else {
|
||||
return Err((StatusCode::UNAUTHORIZED, "not bound".to_string()));
|
||||
};
|
||||
let key_check_hex = bound
|
||||
.key_check_hex
|
||||
.as_deref()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "key_check missing".to_string()))?;
|
||||
verify_key_check_hex(key, key_check_hex)?;
|
||||
guard.exec_contexts.clear();
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: key.to_string(),
|
||||
expires_at: Instant::now() + ttl,
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
axum::Json(json!({"ok": true, "ttl_secs": ttl.as_secs()})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn lock(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_unlock_and_exec();
|
||||
(StatusCode::OK, axum::Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let payload = status_payload(&state).await;
|
||||
(StatusCode::OK, axum::Json(payload))
|
||||
}
|
||||
|
||||
pub async fn status_payload(state: &AppState) -> serde_json::Value {
|
||||
refresh_bound_state(state).await;
|
||||
let now = Instant::now();
|
||||
let mut guard = state.cache.write().await;
|
||||
let unlocked = guard
|
||||
.unlock
|
||||
.as_ref()
|
||||
.is_some_and(|u| u.expires_at > now && !u.encryption_key_hex.is_empty());
|
||||
let expires_in_secs = guard
|
||||
.unlock
|
||||
.as_ref()
|
||||
.and_then(|u| (u.expires_at > now).then_some(u.expires_at.duration_since(now).as_secs()));
|
||||
if guard.unlock.as_ref().is_some_and(|u| u.expires_at <= now) {
|
||||
guard.clear_unlock_and_exec();
|
||||
}
|
||||
let state_name = guard.phase(now);
|
||||
let bound = guard.bound.as_ref().map(|b| {
|
||||
json!({
|
||||
"user_id": b.user_id,
|
||||
"key_version": b.key_version,
|
||||
"bound_for_secs": b.bound_at.elapsed().as_secs(),
|
||||
})
|
||||
});
|
||||
let pending_bind = guard.pending_bind.as_ref().map(|pending| {
|
||||
json!({
|
||||
"bind_id": pending.bind_id,
|
||||
"device_code": pending.device_code,
|
||||
"approve_url": pending.approve_url,
|
||||
"expires_in_secs": pending.expires_at.saturating_duration_since(now).as_secs(),
|
||||
"started_for_secs": pending.started_at.elapsed().as_secs(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"state": state_name,
|
||||
"bound": bound,
|
||||
"pending_bind": pending_bind,
|
||||
"unlocked": unlocked,
|
||||
"expires_in_secs": expires_in_secs,
|
||||
"cached_targets": guard.exec_contexts.len(),
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"unlock_url": format!("http://{}/unlock", state.config.bind),
|
||||
"mcp_url": format!("http://{}/mcp", state.config.bind),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use secrets_core::crypto::encrypt;
|
||||
|
||||
#[test]
|
||||
fn verify_key_check_accepts_matching_key() {
|
||||
let key_hex = "11".repeat(32);
|
||||
let key = extract_key_from_hex(&key_hex).unwrap();
|
||||
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
|
||||
let ciphertext_hex = hex::encode_hex(&ciphertext);
|
||||
assert!(verify_key_check_hex(&key_hex, &ciphertext_hex).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_key_check_rejects_wrong_key() {
|
||||
let correct_key_hex = "11".repeat(32);
|
||||
let wrong_key_hex = "22".repeat(32);
|
||||
let key = extract_key_from_hex(&correct_key_hex).unwrap();
|
||||
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
|
||||
let ciphertext_hex = hex::encode_hex(&ciphertext);
|
||||
let err = verify_key_check_hex(&wrong_key_hex, &ciphertext_hex).unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
57
crates/secrets-mcp/CHANGELOG.md
Normal file
57
crates/secrets-mcp/CHANGELOG.md
Normal file
@@ -0,0 +1,57 @@
|
||||
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
|
||||
|
||||
## [0.6.0] - 2026-04-12
|
||||
|
||||
### Changed
|
||||
|
||||
- 重构 `secrets-mcp-local` 为本地 MCP 服务:`initialize` / `tools/list` 在未绑定、未解锁时也始终成功,不再通过连接级 `401` 让 MCP 客户端误判为服务离线。
|
||||
- 本地 gateway 改为三态工具面:`bootstrap` / `pendingUnlock` / `ready`;未就绪时仅暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` 等 bootstrap 工具。
|
||||
- 本地首页改为真实 onboarding 页面:可直接发起绑定、展示 `approve_url`、轮询授权结果,并衔接本地 unlock;不再要求用户手工拼 `curl` 请求。
|
||||
- 本地绑定闭环改为持久化短时会话:远程 `secrets-mcp` 新增 `local_mcp_bind_sessions` 存储绑定确认状态,避免仅靠单进程内存状态。
|
||||
- 本地解锁增加 `key_check` 校验与生命周期收敛:浏览器内先验证密码短语,再缓存本地 unlock;当远程 `key_version` 变化、API key 失效或绑定用户缺失时,本地自动失效 unlock 或清除 bound 状态。
|
||||
- 远程 `secrets-mcp` 新增 `/api/local-mcp/entries/find|search|history|overview|delete-preview|{id}/secrets` JSON API;local gateway 的发现、预览删除与解密读取已切到这些 HTTP API,不再依赖远程 `/mcp` 作为运行时后端。
|
||||
- 本地 gateway 新增 `target_exec` 通用代执行能力:AI 可先发现服务器或 API 服务条目,再由 local gateway 内部读取条目密钥并注入 `TARGET_*` 环境变量执行标准命令;执行上下文按 `entry_id` 本地缓存,可在 unlock 生命周期内复用。
|
||||
|
||||
## [0.5.28] - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- 工作区新增 **`secrets-mcp-local`** 并升级为本地 MCP 服务:支持 `bind/start -> approve -> bind/exchange -> /unlock` 闭环,复用远程 Web 会话完成本地绑定,浏览器内派生后按 TTL 缓存解锁状态。
|
||||
- 远程 `secrets-mcp` 新增本地绑定 API:`/api/local-mcp/bind/start`、`/api/local-mcp/bind/approve`、`/api/local-mcp/bind/exchange` 以及确认页 `/local-mcp/approve`。
|
||||
|
||||
## [0.5.27] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Web **`/entries`**:按 **tags** 筛选(逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致);folder 标签计数、分页与筛选栏状态同步保留 `tags`。
|
||||
|
||||
## [0.5.26] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`。
|
||||
|
||||
## [0.5.25] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Google OAuth:token / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s);失败时区分超时、连接错误,并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant`、`redirect_uri_mismatch`)。
|
||||
|
||||
## [0.5.24] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
|
||||
|
||||
## [0.5.23] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
|
||||
|
||||
## [0.5.22] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Dashboard(MCP)页脚版本旁增加「变更记录」链接,打开本变更说明页。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.3.6"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -17,9 +17,10 @@ rmcp = { version = "1", features = ["server", "macros", "transport-streamable-ht
|
||||
axum = "0.8"
|
||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
tower-sessions = "0.14"
|
||||
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
|
||||
governor = { version = "0.10", features = ["std", "jitter"] }
|
||||
time = "0.3"
|
||||
|
||||
# OAuth (manual token exchange via reqwest)
|
||||
@@ -33,7 +34,6 @@ anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
rand.workspace = true
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -44,3 +44,5 @@ dotenvy.workspace = true
|
||||
urlencoding = "2"
|
||||
schemars = "1"
|
||||
http = "1"
|
||||
url = "2"
|
||||
pulldown-cmark = "0.13.3"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Request, State},
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
@@ -11,29 +9,14 @@ use uuid::Uuid;
|
||||
|
||||
use secrets_core::service::api_key::validate_api_key;
|
||||
|
||||
use crate::client_ip;
|
||||
|
||||
/// Injected into request extensions after Bearer token validation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
fn log_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())
|
||||
}
|
||||
|
||||
/// Axum middleware that validates Bearer API keys for the /mcp route.
|
||||
/// Passes all non-MCP paths through without authentication.
|
||||
pub async fn bearer_auth_middleware(
|
||||
@@ -43,7 +26,7 @@ pub async fn bearer_auth_middleware(
|
||||
) -> Result<Response, StatusCode> {
|
||||
let path = req.uri().path();
|
||||
let method = req.method().as_str();
|
||||
let client_ip = log_client_ip(&req);
|
||||
let client_ip = client_ip::extract_client_ip(&req);
|
||||
|
||||
// Only authenticate /mcp paths
|
||||
if !path.starts_with("/mcp") {
|
||||
@@ -66,7 +49,7 @@ pub async fn bearer_auth_middleware(
|
||||
tracing::warn!(
|
||||
method,
|
||||
path,
|
||||
client_ip = client_ip.as_deref(),
|
||||
%client_ip,
|
||||
"invalid Authorization header format on /mcp (expected Bearer …)"
|
||||
);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
@@ -75,7 +58,7 @@ pub async fn bearer_auth_middleware(
|
||||
tracing::warn!(
|
||||
method,
|
||||
path,
|
||||
client_ip = client_ip.as_deref(),
|
||||
%client_ip,
|
||||
"missing Authorization header on /mcp"
|
||||
);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
@@ -93,7 +76,7 @@ pub async fn bearer_auth_middleware(
|
||||
tracing::warn!(
|
||||
method,
|
||||
path,
|
||||
client_ip = client_ip.as_deref(),
|
||||
%client_ip,
|
||||
key_prefix = %&raw_key.chars().take(12).collect::<String>(),
|
||||
key_len = raw_key.len(),
|
||||
"invalid api key (not found in database — e.g. revoked key or DB was reset; update MCP client Bearer token)"
|
||||
@@ -104,7 +87,7 @@ pub async fn bearer_auth_middleware(
|
||||
tracing::error!(
|
||||
method,
|
||||
path,
|
||||
client_ip = client_ip.as_deref(),
|
||||
%client_ip,
|
||||
error = %e,
|
||||
"api key validation error"
|
||||
);
|
||||
|
||||
85
crates/secrets-mcp/src/client_ip.rs
Normal file
85
crates/secrets-mcp/src/client_ip.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use axum::extract::Request;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
/// Extract the client IP from a request.
|
||||
///
|
||||
/// When the `TRUST_PROXY` environment variable is set to `1` or `true`, the
|
||||
/// `X-Forwarded-For` and `X-Real-IP` headers are consulted first, which is
|
||||
/// appropriate when the service runs behind a trusted reverse proxy (e.g.
|
||||
/// Caddy). Otherwise — or if those headers are absent/empty — the direct TCP
|
||||
/// connection address from `ConnectInfo` is used.
|
||||
///
|
||||
/// **Important**: only enable `TRUST_PROXY` when the application is guaranteed
|
||||
/// to receive traffic exclusively through a controlled reverse proxy. Enabling
|
||||
/// it on a directly-exposed port allows clients to spoof their IP address and
|
||||
/// bypass per-IP rate limiting.
|
||||
pub fn extract_client_ip(req: &Request) -> String {
|
||||
if trust_proxy_enabled() {
|
||||
if let Some(ip) = forwarded_for_ip(req.headers()) {
|
||||
return ip;
|
||||
}
|
||||
if let Some(ip) = real_ip(req.headers()) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
connect_info_ip(req).unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Extract the client IP from individual header map and socket address components.
|
||||
///
|
||||
/// This variant is used by handlers that receive headers and connect info as
|
||||
/// separate axum extractor parameters (e.g. OAuth callback handlers).
|
||||
/// The same `TRUST_PROXY` logic applies.
|
||||
pub fn extract_client_ip_parts(
|
||||
headers: &axum::http::HeaderMap,
|
||||
addr: std::net::SocketAddr,
|
||||
) -> String {
|
||||
if trust_proxy_enabled() {
|
||||
if let Some(ip) = forwarded_for_ip(headers) {
|
||||
return ip;
|
||||
}
|
||||
if let Some(ip) = real_ip(headers) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
addr.ip().to_string()
|
||||
}
|
||||
|
||||
fn trust_proxy_enabled() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
matches!(
|
||||
std::env::var("TRUST_PROXY").as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("yes")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn forwarded_for_ip(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers.get("x-forwarded-for")?.to_str().ok()?;
|
||||
let first = value.split(',').next()?.trim();
|
||||
if first.is_empty() {
|
||||
None
|
||||
} else {
|
||||
validate_ip(first)
|
||||
}
|
||||
}
|
||||
|
||||
fn real_ip(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers.get("x-real-ip")?.to_str().ok()?;
|
||||
let ip = value.trim();
|
||||
if ip.is_empty() { None } else { validate_ip(ip) }
|
||||
}
|
||||
|
||||
/// Validate that a string is a valid IP address.
|
||||
/// Returns Some(ip) if valid, None otherwise.
|
||||
fn validate_ip(s: &str) -> Option<String> {
|
||||
s.parse::<IpAddr>().ok().map(|ip| ip.to_string())
|
||||
}
|
||||
|
||||
fn connect_info_ip(req: &Request) -> Option<String> {
|
||||
req.extensions()
|
||||
.get::<axum::extract::ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
}
|
||||
54
crates/secrets-mcp/src/error.rs
Normal file
54
crates/secrets-mcp/src/error.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use secrets_core::error::AppError;
|
||||
|
||||
/// Map a structured `AppError` to an MCP protocol error.
|
||||
///
|
||||
/// This replaces the previous pattern of swallowing all errors into `-32603`.
|
||||
pub fn app_error_to_mcp(err: &AppError) -> rmcp::ErrorData {
|
||||
match err {
|
||||
AppError::ConflictSecretName { secret_name } => rmcp::ErrorData::invalid_request(
|
||||
format!(
|
||||
"A secret with the name '{secret_name}' already exists for your account. \
|
||||
Secret names must be unique per user."
|
||||
),
|
||||
None,
|
||||
),
|
||||
AppError::ConflictEntryName { folder, name } => rmcp::ErrorData::invalid_request(
|
||||
format!(
|
||||
"An entry with folder='{folder}' and name='{name}' already exists. \
|
||||
The combination of folder and name must be unique."
|
||||
),
|
||||
None,
|
||||
),
|
||||
AppError::NotFoundEntry => rmcp::ErrorData::invalid_request(
|
||||
"Entry not found. Use secrets_find to discover existing entries.",
|
||||
None,
|
||||
),
|
||||
AppError::NotFoundUser => rmcp::ErrorData::invalid_request("User not found.", None),
|
||||
AppError::NotFoundSecret => rmcp::ErrorData::invalid_request("Secret not found.", None),
|
||||
AppError::AuthenticationFailed => rmcp::ErrorData::invalid_request(
|
||||
"Authentication failed. Please check your API key or login credentials.",
|
||||
None,
|
||||
),
|
||||
AppError::Unauthorized => rmcp::ErrorData::invalid_request(
|
||||
"Unauthorized: you do not have permission to access this resource.",
|
||||
None,
|
||||
),
|
||||
AppError::Validation { message } => rmcp::ErrorData::invalid_request(message.clone(), None),
|
||||
AppError::ConcurrentModification => rmcp::ErrorData::invalid_request(
|
||||
"The entry was modified by another request. Please refresh and try again.",
|
||||
None,
|
||||
),
|
||||
AppError::DecryptionFailed => rmcp::ErrorData::invalid_request(
|
||||
"Decryption failed — the encryption key may be incorrect or does not match the data.",
|
||||
None,
|
||||
),
|
||||
AppError::EncryptionKeyNotSet => rmcp::ErrorData::invalid_request(
|
||||
"Encryption key not set. You must set a passphrase before using this feature.",
|
||||
None,
|
||||
),
|
||||
AppError::Internal(_) => rmcp::ErrorData::internal_error(
|
||||
"Request failed due to a server error. Check service logs if you need details.",
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes, to_bytes},
|
||||
extract::{ConnectInfo, Request},
|
||||
extract::Request,
|
||||
http::{
|
||||
HeaderMap, Method, StatusCode,
|
||||
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
|
||||
header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
|
||||
},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
/// 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.
|
||||
/// tool_name, jsonrpc_id, mcp_session, batch_size, tool_args (non-sensitive
|
||||
/// arguments only), plus masked auth_key / enc_key fingerprints and user_id
|
||||
/// for diagnosing header forwarding issues.
|
||||
///
|
||||
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
|
||||
/// are never logged.
|
||||
/// Sensitive headers (Authorization, X-Encryption-Key) are never logged in
|
||||
/// full — only short fingerprints are emitted.
|
||||
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
@@ -33,6 +36,10 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Capture header fingerprints before consuming the request.
|
||||
let auth_key = mask_bearer(req.headers());
|
||||
let enc_key = mask_enc_key(req.headers());
|
||||
|
||||
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"))
|
||||
@@ -46,6 +53,11 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
||||
let cap = content_len.unwrap_or(0);
|
||||
if cap <= 512 * 1024 {
|
||||
let (parts, body) = req.into_parts();
|
||||
// user_id is available after auth middleware has run (injected into extensions).
|
||||
let user_id = parts
|
||||
.extensions
|
||||
.get::<AuthUser>()
|
||||
.map(|a| a.user_id.to_string());
|
||||
match to_bytes(body, 512 * 1024).await {
|
||||
Ok(bytes) => {
|
||||
let rpc = parse_jsonrpc_meta(&bytes);
|
||||
@@ -62,6 +74,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
||||
ua.as_deref(),
|
||||
content_len,
|
||||
mcp_session.as_deref(),
|
||||
auth_key.as_deref(),
|
||||
&enc_key,
|
||||
user_id.as_deref(),
|
||||
&rpc,
|
||||
);
|
||||
return resp;
|
||||
@@ -78,6 +93,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
||||
ua = ua.as_deref(),
|
||||
content_length = content_len,
|
||||
mcp_session = mcp_session.as_deref(),
|
||||
auth_key = auth_key.as_deref(),
|
||||
enc_key = enc_key.as_str(),
|
||||
user_id = user_id.as_deref(),
|
||||
"mcp request",
|
||||
);
|
||||
return (
|
||||
@@ -160,6 +178,9 @@ fn log_mcp_request(
|
||||
ua: Option<&str>,
|
||||
content_length: Option<u64>,
|
||||
mcp_session: Option<&str>,
|
||||
auth_key: Option<&str>,
|
||||
enc_key: &str,
|
||||
user_id: Option<&str>,
|
||||
rpc: &JsonRpcMeta,
|
||||
) {
|
||||
tracing::info!(
|
||||
@@ -175,18 +196,94 @@ fn log_mcp_request(
|
||||
tool = rpc.tool_name.as_deref(),
|
||||
jsonrpc_id = rpc.request_id.as_deref(),
|
||||
batch_size = rpc.batch_size,
|
||||
tool_args = rpc.tool_args.as_deref(),
|
||||
auth_key,
|
||||
enc_key,
|
||||
user_id,
|
||||
"mcp request",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sensitive header masking ──────────────────────────────────────────────────
|
||||
|
||||
/// Mask a Bearer token: emit only the first 12 characters followed by `…`.
|
||||
/// Returns `None` if the Authorization header is absent or not a Bearer token.
|
||||
/// Example: `sk_90c88844e4e5…`
|
||||
fn mask_bearer(headers: &HeaderMap) -> Option<String> {
|
||||
let val = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = val.strip_prefix("Bearer ")?.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if token.len() > 12 {
|
||||
Some(format!("{}…", &token[..12]))
|
||||
} else {
|
||||
Some(token.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint the X-Encryption-Key header.
|
||||
///
|
||||
/// Emits first 4 chars, last 4 chars, and raw byte length, e.g. `146b…5516(64)`.
|
||||
/// Returns `"absent"` when the header is missing. Reveals enough to confirm
|
||||
/// which key arrived and whether it was truncated or padded, without revealing
|
||||
/// the full value.
|
||||
fn mask_enc_key(headers: &HeaderMap) -> String {
|
||||
match headers
|
||||
.get("x-encryption-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(val) => {
|
||||
let raw_len = val.len();
|
||||
let t = val.trim();
|
||||
let len = t.len();
|
||||
if len >= 8 {
|
||||
let prefix = &t[..4];
|
||||
let suffix = &t[len - 4..];
|
||||
if raw_len != len {
|
||||
// Trailing/leading whitespace detected — extra diagnostic.
|
||||
format!("{prefix}…{suffix}({len}, raw={raw_len})")
|
||||
} else {
|
||||
format!("{prefix}…{suffix}({len})")
|
||||
}
|
||||
} else {
|
||||
format!("…({len})")
|
||||
}
|
||||
}
|
||||
None => "absent".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
|
||||
|
||||
/// Safe (non-sensitive) argument keys that may be included verbatim in logs.
|
||||
/// Keys NOT in this list (e.g. `secrets`, `secrets_obj`, `meta_obj`,
|
||||
/// `encryption_key`) are silently dropped.
|
||||
const SAFE_ARG_KEYS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"name_query",
|
||||
"folder",
|
||||
"type",
|
||||
"entry_type",
|
||||
"field",
|
||||
"query",
|
||||
"tags",
|
||||
"limit",
|
||||
"offset",
|
||||
"format",
|
||||
"dry_run",
|
||||
"prefix",
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct JsonRpcMeta {
|
||||
request_id: Option<String>,
|
||||
rpc_method: Option<String>,
|
||||
tool_name: Option<String>,
|
||||
batch_size: Option<usize>,
|
||||
/// Non-sensitive tool call arguments for diagnostic logging.
|
||||
tool_args: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
|
||||
@@ -216,12 +313,47 @@ fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
|
||||
.pointer("/params/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let tool_args = extract_tool_args(value);
|
||||
|
||||
JsonRpcMeta {
|
||||
request_id,
|
||||
rpc_method,
|
||||
tool_name,
|
||||
batch_size: None,
|
||||
tool_args,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a compact summary of non-sensitive tool arguments for logging.
|
||||
/// Only keys listed in `SAFE_ARG_KEYS` are included.
|
||||
fn extract_tool_args(value: &serde_json::Value) -> Option<String> {
|
||||
let args = value.pointer("/params/arguments")?;
|
||||
let obj = args.as_object()?;
|
||||
let pairs: Vec<String> = obj
|
||||
.iter()
|
||||
.filter(|(k, v)| SAFE_ARG_KEYS.contains(&k.as_str()) && !v.is_null())
|
||||
.map(|(k, v)| format!("{}={}", k, summarize_value(v)))
|
||||
.collect();
|
||||
if pairs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(pairs.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a short, log-safe representation of a JSON value.
|
||||
fn summarize_value(v: &serde_json::Value) -> String {
|
||||
match v {
|
||||
serde_json::Value::String(s) => {
|
||||
if s.len() > 64 {
|
||||
format!("\"{}…\"", &s[..64])
|
||||
} else {
|
||||
format!("\"{s}\"")
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => format!("[…{}]", arr.len()),
|
||||
serde_json::Value::Object(_) => "{…}".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,18 +377,5 @@ fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName)
|
||||
}
|
||||
|
||||
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())
|
||||
crate::client_ip::extract_client_ip(req).into()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
mod auth;
|
||||
mod client_ip;
|
||||
mod error;
|
||||
mod logging;
|
||||
mod oauth;
|
||||
mod rate_limit;
|
||||
mod tools;
|
||||
mod validation;
|
||||
mod web;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
@@ -23,6 +26,7 @@ use tracing_subscriber::fmt::time::FormatTime;
|
||||
|
||||
use secrets_core::config::resolve_db_config;
|
||||
use secrets_core::db::{create_pool, migrate};
|
||||
use secrets_core::service::delete::purge_expired_deleted_entries;
|
||||
|
||||
use crate::oauth::OAuthConfig;
|
||||
use crate::tools::SecretsService;
|
||||
@@ -40,6 +44,14 @@ fn load_env_var(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Pretty-print bind address in logs (`127.0.0.1` → `localhost`); actual socket bind unchanged.
|
||||
fn listen_addr_log_display(bind_addr: &str) -> String {
|
||||
bind_addr
|
||||
.strip_prefix("127.0.0.1:")
|
||||
.map(|port| format!("localhost:{port}"))
|
||||
.unwrap_or_else(|| bind_addr.to_string())
|
||||
}
|
||||
|
||||
fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option<OAuthConfig> {
|
||||
let client_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
|
||||
let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
|
||||
@@ -132,11 +144,11 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
|
||||
// ── MCP service ───────────────────────────────────────────────────────────
|
||||
let pool_arc = Arc::new(pool.clone());
|
||||
let pool_for_mcp = pool.clone();
|
||||
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || {
|
||||
let p = pool_arc.clone();
|
||||
let p = pool_for_mcp.clone();
|
||||
Ok(SecretsService::new(p))
|
||||
},
|
||||
LocalSessionManager::default().into(),
|
||||
@@ -144,10 +156,21 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
// CORS: restrict origins in production, allow all in development
|
||||
let is_production = matches!(
|
||||
load_env_var("SECRETS_ENV")
|
||||
.as_deref()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("prod" | "production")
|
||||
);
|
||||
|
||||
let cors = build_cors_layer(&base_url, is_production);
|
||||
|
||||
// Rate limiting
|
||||
let rate_limit_state = rate_limit::RateLimitState::new();
|
||||
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
|
||||
let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
|
||||
|
||||
let router = Router::new()
|
||||
.merge(web::web_router())
|
||||
@@ -159,8 +182,15 @@ async fn main() -> Result<()> {
|
||||
pool,
|
||||
auth::bearer_auth_middleware,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
rate_limit_state.clone(),
|
||||
rate_limit::rate_limit_middleware,
|
||||
))
|
||||
.layer(session_layer)
|
||||
.layer(cors)
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(
|
||||
10 * 1024 * 1024,
|
||||
))
|
||||
.with_state(app_state);
|
||||
|
||||
// ── Start server ──────────────────────────────────────────────────────────
|
||||
@@ -168,7 +198,10 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", bind_addr))?;
|
||||
|
||||
tracing::info!("Secrets MCP Server listening on http://{}", bind_addr);
|
||||
tracing::info!(
|
||||
"Secrets MCP Server listening on http://{}",
|
||||
listen_addr_log_display(&bind_addr)
|
||||
);
|
||||
tracing::info!("MCP endpoint: {}/mcp", base_url);
|
||||
|
||||
axum::serve(
|
||||
@@ -180,12 +213,154 @@ async fn main() -> Result<()> {
|
||||
.context("server error")?;
|
||||
|
||||
session_cleanup.abort();
|
||||
rate_limit_cleanup.abort();
|
||||
recycle_bin_cleanup.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_recycle_bin_cleanup_task(pool: PgPool) {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match purge_expired_deleted_entries(&pool).await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!(purged_count = count, "purged expired recycle bin entries");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install CTRL+C signal handler");
|
||||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
tracing::info!("Shutting down gracefully...");
|
||||
}
|
||||
|
||||
/// Production CORS allowed headers.
|
||||
///
|
||||
/// When adding a new custom header to the MCP or Web API, this list must be
|
||||
/// updated accordingly — otherwise browsers will block the request during
|
||||
/// the CORS preflight check.
|
||||
fn production_allowed_headers() -> [axum::http::HeaderName; 5] {
|
||||
[
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderName::from_static("x-encryption-key"),
|
||||
axum::http::HeaderName::from_static("mcp-session-id"),
|
||||
axum::http::HeaderName::from_static("x-mcp-session"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Production CORS allowed methods.
|
||||
///
|
||||
/// Keep this list explicit because tower-http rejects
|
||||
/// `allow_credentials(true)` together with `allow_methods(Any)`.
|
||||
fn production_allowed_methods() -> [axum::http::Method; 5] {
|
||||
[
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::PATCH,
|
||||
axum::http::Method::DELETE,
|
||||
axum::http::Method::OPTIONS,
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the CORS layer for the application.
|
||||
///
|
||||
/// In production mode the origin is restricted to the BASE_URL origin
|
||||
/// (scheme://host:port, path stripped) and credentials are allowed.
|
||||
/// `allow_headers` and `allow_methods` use explicit whitelists to avoid the
|
||||
/// tower-http restriction on `allow_credentials(true)` + wildcards.
|
||||
///
|
||||
/// In development mode all origins, methods and headers are allowed.
|
||||
fn build_cors_layer(base_url: &str, is_production: bool) -> CorsLayer {
|
||||
if is_production {
|
||||
let allowed_origin = if let Ok(parsed) = base_url.parse::<url::Url>() {
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
origin
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin))
|
||||
} else {
|
||||
base_url
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url))
|
||||
};
|
||||
CorsLayer::new()
|
||||
.allow_origin(allowed_origin)
|
||||
.allow_methods(production_allowed_methods())
|
||||
.allow_headers(production_allowed_headers())
|
||||
.allow_credentials(true)
|
||||
} else {
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production_cors_does_not_panic() {
|
||||
let layer = build_cors_layer("https://secrets.example.com/app", true);
|
||||
let _ = layer;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_cors_headers_include_all_required() {
|
||||
let headers = production_allowed_headers();
|
||||
let names: Vec<&str> = headers.iter().map(|h| h.as_str()).collect();
|
||||
assert!(names.contains(&"authorization"));
|
||||
assert!(names.contains(&"content-type"));
|
||||
assert!(names.contains(&"x-encryption-key"));
|
||||
assert!(names.contains(&"mcp-session-id"));
|
||||
assert!(names.contains(&"x-mcp-session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_cors_methods_include_all_required() {
|
||||
let methods = production_allowed_methods();
|
||||
assert!(methods.contains(&axum::http::Method::GET));
|
||||
assert!(methods.contains(&axum::http::Method::POST));
|
||||
assert!(methods.contains(&axum::http::Method::PATCH));
|
||||
assert!(methods.contains(&axum::http::Method::DELETE));
|
||||
assert!(methods.contains(&axum::http::Method::OPTIONS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_cors_normalizes_base_url_with_path() {
|
||||
let url = url::Url::parse("https://secrets.example.com/secrets/app").unwrap();
|
||||
let origin = url.origin().ascii_serialization();
|
||||
assert_eq!(origin, "https://secrets.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_cors_allows_everything() {
|
||||
let layer = build_cors_layer("http://localhost:9315", false);
|
||||
let _ = layer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{OAuthConfig, OAuthUserInfo};
|
||||
|
||||
/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed.
|
||||
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
@@ -20,14 +25,28 @@ struct UserInfo {
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!(
|
||||
"timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)",
|
||||
OAUTH_HTTP_TIMEOUT.as_secs()
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!("connection error to Google OAuth: {e}")
|
||||
} else {
|
||||
anyhow::Error::new(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange authorization code for tokens and fetch user profile.
|
||||
pub async fn exchange_code(
|
||||
client: &reqwest::Client,
|
||||
config: &OAuthConfig,
|
||||
code: &str,
|
||||
) -> Result<OAuthUserInfo> {
|
||||
let token_resp: TokenResponse = client
|
||||
let token_http = client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.timeout(OAUTH_HTTP_TIMEOUT)
|
||||
.form(&[
|
||||
("code", code),
|
||||
("client_id", &config.client_id),
|
||||
@@ -37,24 +56,55 @@ pub async fn exchange_code(
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("failed to exchange Google code")?
|
||||
.error_for_status()
|
||||
.context("Google token endpoint error")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse Google token response")?;
|
||||
.map_err(map_reqwest_send_err)
|
||||
.context("Google token HTTP request failed")?;
|
||||
|
||||
let user: UserInfo = client
|
||||
let status = token_http.status();
|
||||
let body_bytes = token_http
|
||||
.bytes()
|
||||
.await
|
||||
.context("read Google token response body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let body_lossy = String::from_utf8_lossy(&body_bytes);
|
||||
tracing::warn!(%status, body = %body_lossy, "Google token endpoint error");
|
||||
anyhow::bail!(
|
||||
"Google token error {}: {}",
|
||||
status,
|
||||
body_lossy.chars().take(512).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?;
|
||||
|
||||
let user_http = client
|
||||
.get("https://openidconnect.googleapis.com/v1/userinfo")
|
||||
.timeout(OAUTH_HTTP_TIMEOUT)
|
||||
.bearer_auth(&token_resp.access_token)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to fetch Google userinfo")?
|
||||
.error_for_status()
|
||||
.context("Google userinfo endpoint error")?
|
||||
.json()
|
||||
.map_err(map_reqwest_send_err)
|
||||
.context("Google userinfo HTTP request failed")?;
|
||||
|
||||
let status = user_http.status();
|
||||
let body_bytes = user_http
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to parse Google userinfo")?;
|
||||
.context("read Google userinfo body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let body_lossy = String::from_utf8_lossy(&body_bytes);
|
||||
tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error");
|
||||
anyhow::bail!(
|
||||
"Google userinfo error {}: {}",
|
||||
status,
|
||||
body_lossy.chars().take(512).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let user: UserInfo =
|
||||
serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?;
|
||||
|
||||
Ok(OAuthUserInfo {
|
||||
provider: "google".to_string(),
|
||||
|
||||
@@ -41,5 +41,5 @@ pub fn random_state() -> String {
|
||||
use rand::RngExt;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
secrets_core::crypto::hex::encode_hex(&bytes)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{OAuthConfig, OAuthUserInfo};
|
||||
/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // Placeholder — implement when WeChat login is needed.
|
||||
pub async fn exchange_code(
|
||||
_client: &reqwest::Client,
|
||||
_config: &OAuthConfig,
|
||||
|
||||
160
crates/secrets-mcp/src/rate_limit.rs
Normal file
160
crates/secrets-mcp/src/rate_limit.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use governor::{
|
||||
Quota, RateLimiter,
|
||||
clock::{Clock, DefaultClock},
|
||||
state::{InMemoryState, NotKeyed, keyed::DashMapStateStore},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::client_ip;
|
||||
|
||||
/// Per-IP rate limiter (keyed by client IP string)
|
||||
type IpRateLimiter = RateLimiter<String, DashMapStateStore<String>, DefaultClock>;
|
||||
|
||||
/// Global rate limiter (not keyed)
|
||||
type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
|
||||
|
||||
/// Parse a u32 env value into NonZeroU32, logging a warning and falling back
|
||||
/// to the default if the value is zero.
|
||||
fn nz_or_log(value: u32, default: u32, name: &str) -> NonZeroU32 {
|
||||
NonZeroU32::new(value).unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
configured = value,
|
||||
default,
|
||||
"{name} must be non-zero, using default"
|
||||
);
|
||||
NonZeroU32::new(default).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimitState {
|
||||
pub ip_limiter: Arc<IpRateLimiter>,
|
||||
pub global_limiter: Arc<GlobalRateLimiter>,
|
||||
}
|
||||
|
||||
impl RateLimitState {
|
||||
/// Create a new RateLimitState with default limits.
|
||||
///
|
||||
/// Default limits (can be overridden via environment variables):
|
||||
/// - Global: 100 req/s, burst 200
|
||||
/// - Per-IP: 20 req/s, burst 40
|
||||
pub fn new() -> Self {
|
||||
let global_rate = std::env::var("RATE_LIMIT_GLOBAL_PER_SECOND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(100);
|
||||
|
||||
let global_burst = std::env::var("RATE_LIMIT_GLOBAL_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
let ip_rate = std::env::var("RATE_LIMIT_IP_PER_SECOND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(20);
|
||||
|
||||
let ip_burst = std::env::var("RATE_LIMIT_IP_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(40);
|
||||
|
||||
let global_rate_nz = nz_or_log(global_rate, 100, "RATE_LIMIT_GLOBAL_PER_SECOND");
|
||||
let global_burst_nz = nz_or_log(global_burst, 200, "RATE_LIMIT_GLOBAL_BURST");
|
||||
let ip_rate_nz = nz_or_log(ip_rate, 20, "RATE_LIMIT_IP_PER_SECOND");
|
||||
let ip_burst_nz = nz_or_log(ip_burst, 40, "RATE_LIMIT_IP_BURST");
|
||||
|
||||
let global_quota = Quota::per_second(global_rate_nz).allow_burst(global_burst_nz);
|
||||
let ip_quota = Quota::per_second(ip_rate_nz).allow_burst(ip_burst_nz);
|
||||
|
||||
tracing::info!(
|
||||
global_rate = global_rate_nz.get(),
|
||||
global_burst = global_burst_nz.get(),
|
||||
ip_rate = ip_rate_nz.get(),
|
||||
ip_burst = ip_burst_nz.get(),
|
||||
"rate limiter initialized"
|
||||
);
|
||||
|
||||
Self {
|
||||
global_limiter: Arc::new(RateLimiter::direct(global_quota)),
|
||||
ip_limiter: Arc::new(RateLimiter::dashmap(ip_quota)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting middleware function.
|
||||
///
|
||||
/// Checks both global and per-IP rate limits before allowing the request through.
|
||||
/// Returns 429 Too Many Requests if either limit is exceeded.
|
||||
pub async fn rate_limit_middleware(
|
||||
State(rl): State<RateLimitState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, Response> {
|
||||
// Check global rate limit first
|
||||
if let Err(negative) = rl.global_limiter.check() {
|
||||
let retry_after = negative.wait_time_from(DefaultClock::default().now());
|
||||
tracing::warn!(
|
||||
retry_after_secs = retry_after.as_secs(),
|
||||
"global rate limit exceeded"
|
||||
);
|
||||
return Err(too_many_requests_response(Some(retry_after)));
|
||||
}
|
||||
|
||||
// Check per-IP rate limit
|
||||
let key = client_ip::extract_client_ip(&req);
|
||||
if let Err(negative) = rl.ip_limiter.check_key(&key) {
|
||||
let retry_after = negative.wait_time_from(DefaultClock::default().now());
|
||||
tracing::warn!(
|
||||
client_ip = %key,
|
||||
retry_after_secs = retry_after.as_secs(),
|
||||
"per-IP rate limit exceeded"
|
||||
);
|
||||
return Err(too_many_requests_response(Some(retry_after)));
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Start a background task to clean up expired rate limiter entries.
|
||||
///
|
||||
/// This should be called once during application startup.
|
||||
/// The task runs every 60 seconds and will be aborted on shutdown.
|
||||
pub fn spawn_cleanup_task(ip_limiter: Arc<IpRateLimiter>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
ip_limiter.retain_recent();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a 429 Too Many Requests response.
|
||||
fn too_many_requests_response(retry_after: Option<Duration>) -> Response {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
||||
|
||||
if let Some(duration) = retry_after {
|
||||
let secs = duration.as_secs().max(1);
|
||||
if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
|
||||
headers.insert("Retry-After", value);
|
||||
}
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"error": "Too many requests, please try again later"
|
||||
});
|
||||
|
||||
(StatusCode::TOO_MANY_REQUESTS, headers, body.to_string()).into_response()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
149
crates/secrets-mcp/src/validation.rs
Normal file
149
crates/secrets-mcp/src/validation.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
/// Validation constants for input field lengths.
|
||||
pub const MAX_NAME_LENGTH: usize = 256;
|
||||
pub const MAX_FOLDER_LENGTH: usize = 128;
|
||||
pub const MAX_ENTRY_TYPE_LENGTH: usize = 64;
|
||||
pub const MAX_NOTES_LENGTH: usize = 10000;
|
||||
pub const MAX_TAG_LENGTH: usize = 64;
|
||||
pub const MAX_TAG_COUNT: usize = 50;
|
||||
pub const MAX_META_KEY_LENGTH: usize = 128;
|
||||
pub const MAX_META_VALUE_LENGTH: usize = 4096;
|
||||
pub const MAX_META_COUNT: usize = 100;
|
||||
|
||||
/// Validate input field lengths for MCP tools.
|
||||
///
|
||||
/// Returns an error if any field exceeds its maximum length.
|
||||
pub fn validate_input_lengths(
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
entry_type: Option<&str>,
|
||||
notes: Option<&str>,
|
||||
) -> Result<(), rmcp::ErrorData> {
|
||||
if name.chars().count() > MAX_NAME_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("name must be at most {} characters", MAX_NAME_LENGTH),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if let Some(folder) = folder
|
||||
&& folder.chars().count() > MAX_FOLDER_LENGTH
|
||||
{
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("folder must be at most {} characters", MAX_FOLDER_LENGTH),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if let Some(entry_type) = entry_type
|
||||
&& entry_type.chars().count() > MAX_ENTRY_TYPE_LENGTH
|
||||
{
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("type must be at most {} characters", MAX_ENTRY_TYPE_LENGTH),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if let Some(notes) = notes
|
||||
&& notes.chars().count() > MAX_NOTES_LENGTH
|
||||
{
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("notes must be at most {} characters", MAX_NOTES_LENGTH),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the tags list.
|
||||
///
|
||||
/// Checks total count and per-tag character length.
|
||||
pub fn validate_tags(tags: &[String]) -> Result<(), rmcp::ErrorData> {
|
||||
if tags.len() > MAX_TAG_COUNT {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("at most {} tags are allowed", MAX_TAG_COUNT),
|
||||
None,
|
||||
));
|
||||
}
|
||||
for tag in tags {
|
||||
if tag.chars().count() > MAX_TAG_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"tag '{}' exceeds the maximum length of {} characters",
|
||||
tag, MAX_TAG_LENGTH
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate metadata KV strings (key=value / key:=json format).
|
||||
///
|
||||
/// Checks total count and per-key/per-value character lengths.
|
||||
/// This is a best-effort check on the raw KV strings before parsing;
|
||||
/// keys containing `:` path separators are checked as a whole.
|
||||
pub fn validate_meta_entries(entries: &[String]) -> Result<(), rmcp::ErrorData> {
|
||||
if entries.len() > MAX_META_COUNT {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!("at most {} metadata entries are allowed", MAX_META_COUNT),
|
||||
None,
|
||||
));
|
||||
}
|
||||
for entry in entries {
|
||||
// key:=json — check both key and JSON value length
|
||||
if let Some((key, value)) = entry.split_once(":=") {
|
||||
if key.chars().count() > MAX_META_KEY_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"metadata key '{}' exceeds the maximum length of {} characters",
|
||||
key, MAX_META_KEY_LENGTH
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if value.chars().count() > MAX_META_VALUE_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"metadata JSON value for key '{}' exceeds the maximum length of {} characters",
|
||||
key, MAX_META_VALUE_LENGTH
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// key=value or key@path
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
if key.chars().count() > MAX_META_KEY_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"metadata key '{}' exceeds the maximum length of {} characters",
|
||||
key, MAX_META_KEY_LENGTH
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if value.chars().count() > MAX_META_VALUE_LENGTH {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"metadata value for key '{}' exceeds the maximum length of {} characters",
|
||||
key, MAX_META_VALUE_LENGTH
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Fallback: entry without = or := — check total length
|
||||
let max_total = MAX_META_KEY_LENGTH + MAX_META_VALUE_LENGTH;
|
||||
if entry.chars().count() > max_total {
|
||||
let preview = entry.chars().take(50).collect::<String>();
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
format!(
|
||||
"metadata entry '{}' exceeds the maximum length of {} characters",
|
||||
preview, max_total
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
297
crates/secrets-mcp/src/web/account.rs
Normal file
297
crates/secrets-mcp/src/web/account.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use askama::Template;
|
||||
use axum::{Json, extract::State, http::StatusCode, response::Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use secrets_core::crypto::hex;
|
||||
use secrets_core::service::{
|
||||
api_key::{ensure_api_key, regenerate_api_key},
|
||||
user::{change_user_key, get_user_by_id, update_user_key_setup},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard.html")]
|
||||
struct DashboardTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
has_passphrase: bool,
|
||||
base_url: String,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct KeySaltResponse {
|
||||
has_passphrase: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key_check: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct KeySetupRequest {
|
||||
/// Hex-encoded 32-byte random salt
|
||||
salt: String,
|
||||
/// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key
|
||||
key_check: String,
|
||||
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct KeySetupResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct KeyChangeRequest {
|
||||
/// Old derived key as 64-char hex — used to decrypt existing secrets
|
||||
old_key: String,
|
||||
/// New derived key as 64-char hex — used to re-encrypt secrets
|
||||
new_key: String,
|
||||
/// New 32-byte hex salt
|
||||
salt: String,
|
||||
/// New key_check: AES-256-GCM of KEY_CHECK_PLAINTEXT with the new key (hex)
|
||||
key_check: String,
|
||||
/// New key derivation parameters
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct ApiKeyResponse {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
pub(super) async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user = match require_valid_user(&state.pool, &session, "dashboard").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub(super) async fn api_key_salt(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Json<KeySaltResponse>, StatusCode> {
|
||||
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
if user.key_salt.is_none() {
|
||||
return Ok(Json(KeySaltResponse {
|
||||
has_passphrase: false,
|
||||
salt: None,
|
||||
key_check: None,
|
||||
params: None,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(KeySaltResponse {
|
||||
has_passphrase: true,
|
||||
salt: user.key_salt.as_deref().map(hex::encode_hex),
|
||||
key_check: user.key_check.as_deref().map(hex::encode_hex),
|
||||
params: user.key_params,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn api_key_setup(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(body): Json<KeySetupRequest>,
|
||||
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
||||
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
// Guard: if a passphrase is already configured, reject and direct to /api/key-change
|
||||
if user.key_salt.is_some() {
|
||||
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
let salt = hex::decode_hex(&body.salt).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-setup salt");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
let key_check = hex::decode_hex(&body.key_check).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-setup key_check");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
if salt.len() != 32 {
|
||||
tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes");
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to update key setup");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(KeySetupResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ── Change passphrase (re-encrypts all secrets) ───────────────────────────────
|
||||
|
||||
pub(super) async fn api_key_change(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(body): Json<KeyChangeRequest>,
|
||||
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
||||
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
// Must have an existing passphrase to change
|
||||
let existing_key_check = user.key_check.ok_or_else(|| {
|
||||
tracing::warn!(%user_id, "key-change called but no passphrase configured; use /api/key-setup");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Validate and decode old key
|
||||
let old_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.old_key).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid old_key hex in key-change");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Verify old_key against the stored key_check
|
||||
let plaintext = secrets_core::crypto::decrypt(&old_key_bytes, &existing_key_check).map_err(|_| {
|
||||
tracing::warn!(%user_id, "key-change rejected: old_key does not match stored key_check");
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
if plaintext != b"secrets-mcp-key-check" {
|
||||
tracing::warn!(%user_id, "key-change rejected: decrypted key_check content mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Validate and decode new key
|
||||
let new_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.new_key).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid new_key hex in key-change");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Decode new salt and key_check
|
||||
let new_salt = hex::decode_hex(&body.salt).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-change salt");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
if new_salt.len() != 32 {
|
||||
tracing::warn!(
|
||||
salt_len = new_salt.len(),
|
||||
"key-change salt must be 32 bytes"
|
||||
);
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let new_key_check = hex::decode_hex(&body.key_check).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-change key_check");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
change_user_key(
|
||||
&state.pool,
|
||||
user_id,
|
||||
&old_key_bytes,
|
||||
&new_key_bytes,
|
||||
&new_salt,
|
||||
&new_key_check,
|
||||
&body.params,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "failed to change user key");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Refresh the session's key_version so the current session is not immediately
|
||||
// invalidated by require_valid_user on the next page load.
|
||||
match get_user_by_id(&state.pool, user_id).await {
|
||||
Ok(Some(updated_user)) => {
|
||||
if let Err(e) = session
|
||||
.insert(SESSION_KEY_VERSION, updated_user.key_version)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(%user_id, "user not found after key change; session not updated");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
|
||||
Ok(Json(KeySetupResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ── API Key management ────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn api_apikey_get(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
||||
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiKeyResponse { api_key }))
|
||||
}
|
||||
|
||||
pub(super) async fn api_apikey_regenerate(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
||||
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let api_key = regenerate_api_key(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "regenerate_api_key failed");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiKeyResponse { api_key }))
|
||||
}
|
||||
73
crates/secrets-mcp/src/web/assets.rs
Normal file
73
crates/secrets-mcp/src/web/assets.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(super) fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(content))
|
||||
.expect("text asset response")
|
||||
}
|
||||
|
||||
pub(super) async fn robots_txt() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../static/robots.txt"),
|
||||
"text/plain; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn llms_txt() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../static/llms.txt"),
|
||||
"text/markdown; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn ai_txt() -> Response {
|
||||
llms_txt().await
|
||||
}
|
||||
|
||||
pub(super) async fn i18n_js() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../templates/i18n.js"),
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) 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),
|
||||
)
|
||||
}
|
||||
104
crates/secrets-mcp/src/web/audit.rs
Normal file
104
crates/secrets-mcp/src/web/audit.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use chrono::SecondsFormat;
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{AUDIT_PAGE_LIMIT, paginate, render_template, require_valid_user};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "audit.html")]
|
||||
struct AuditPageTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
entries: Vec<AuditEntryView>,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
total_count: i64,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
struct AuditEntryView {
|
||||
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
|
||||
created_at_iso: String,
|
||||
action: String,
|
||||
target: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct AuditQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
fn format_audit_target(folder: &str, entry_type: &str, name: &str) -> String {
|
||||
// Auth events (folder="auth") use entry_type/name as provider-scoped target.
|
||||
if folder == "auth" {
|
||||
format!("{}/{}", entry_type, name)
|
||||
} else if !folder.is_empty() && !entry_type.is_empty() {
|
||||
format!("[{}/{}] {}", folder, entry_type, name)
|
||||
} else if !folder.is_empty() {
|
||||
format!("[{}] {}", folder, name)
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn audit_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(aq): Query<AuditQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
use secrets_core::service::audit_log::{count_for_user, list_for_user};
|
||||
|
||||
let user = match require_valid_user(&state.pool, &session, "audit_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let page = aq.page.unwrap_or(1).max(1);
|
||||
|
||||
let total_count = count_for_user(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to count audit log for user");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let (current_page, total_pages, offset) = paginate(page, total_count, AUDIT_PAGE_LIMIT as u32);
|
||||
let actual_offset = i64::from(offset);
|
||||
|
||||
let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load audit log for user");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let entries = rows
|
||||
.into_iter()
|
||||
.map(|row| AuditEntryView {
|
||||
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
action: row.action,
|
||||
target: format_audit_target(&row.folder, &row.entry_type, &row.name),
|
||||
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tmpl = AuditPageTemplate {
|
||||
user_name: user.name.clone(),
|
||||
user_email: user.email.clone().unwrap_or_default(),
|
||||
entries,
|
||||
current_page,
|
||||
total_pages,
|
||||
total_count,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
render_template(tmpl)
|
||||
}
|
||||
360
crates/secrets-mcp/src/web/auth.rs
Normal file
360
crates/secrets-mcp/src/web/auth.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use secrets_core::audit::log_login;
|
||||
use secrets_core::service::user::{
|
||||
OAuthProfile, bind_oauth_account, find_or_create_user, unbind_oauth_account,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::oauth::{OAuthConfig, OAuthUserInfo, google_auth_url, random_state};
|
||||
|
||||
use super::{
|
||||
SESSION_KEY_VERSION, SESSION_LOGIN_PROVIDER, SESSION_OAUTH_BIND_MODE, SESSION_OAUTH_STATE,
|
||||
SESSION_USER_ID, current_user_id, google_cfg, render_template, request_user_agent,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
has_google: bool,
|
||||
base_url: String,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "home.html")]
|
||||
struct HomeTemplate {
|
||||
is_logged_in: bool,
|
||||
base_url: String,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
// ── Home page (public) ───────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn home_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let is_logged_in = current_user_id(&session).await.is_some();
|
||||
let tmpl = HomeTemplate {
|
||||
is_logged_in,
|
||||
base_url: state.base_url.clone(),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
render_template(tmpl)
|
||||
}
|
||||
|
||||
// ── Login page ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn login_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if let Some(_uid) = current_user_id(&session).await {
|
||||
return Ok(Redirect::to("/dashboard").into_response());
|
||||
}
|
||||
|
||||
let tmpl = LoginTemplate {
|
||||
has_google: state.google_config.is_some(),
|
||||
base_url: state.base_url.clone(),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
render_template(tmpl)
|
||||
}
|
||||
|
||||
// ── Google OAuth ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn auth_google(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
let oauth_state = random_state();
|
||||
session
|
||||
.insert(SESSION_OAUTH_STATE, &oauth_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to insert oauth_state into session");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let url = google_auth_url(config, &oauth_state);
|
||||
Ok(Redirect::to(&url).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct OAuthCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn auth_google_callback(
|
||||
State(state): State<AppState>,
|
||||
connect_info: ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
session: Session,
|
||||
Query(params): Query<OAuthCallbackQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let client_ip = Some(crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
connect_info.0,
|
||||
));
|
||||
let user_agent = request_user_agent(&headers);
|
||||
handle_oauth_callback(
|
||||
&state,
|
||||
&session,
|
||||
params,
|
||||
"google",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
|s, cfg, code| {
|
||||
Box::pin(crate::oauth::google::exchange_code(
|
||||
&s.http_client,
|
||||
cfg,
|
||||
code,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Shared OAuth callback handler ─────────────────────────────────────────────
|
||||
|
||||
async fn handle_oauth_callback<F>(
|
||||
state: &AppState,
|
||||
session: &Session,
|
||||
params: OAuthCallbackQuery,
|
||||
provider: &str,
|
||||
client_ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
exchange_fn: F,
|
||||
) -> Result<Response, StatusCode>
|
||||
where
|
||||
F: for<'a> Fn(
|
||||
&'a AppState,
|
||||
&'a OAuthConfig,
|
||||
&'a str,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = anyhow::Result<OAuthUserInfo>> + Send + 'a>,
|
||||
>,
|
||||
{
|
||||
if let Some(err) = params.error {
|
||||
tracing::warn!(provider, error = %err, "OAuth error");
|
||||
return Ok(Redirect::to("/login?error=oauth_error").into_response());
|
||||
}
|
||||
|
||||
let Some(code) = params.code else {
|
||||
tracing::warn!(provider, "OAuth callback missing code");
|
||||
return Ok(Redirect::to("/login?error=oauth_missing_code").into_response());
|
||||
};
|
||||
let Some(returned_state) = params.state.as_deref() else {
|
||||
tracing::warn!(provider, "OAuth callback missing state");
|
||||
return Ok(Redirect::to("/login?error=oauth_missing_state").into_response());
|
||||
};
|
||||
|
||||
let expected_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.map_err(|e| {
|
||||
tracing::error!(provider, error = %e, "failed to read oauth_state from session");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if expected_state.as_deref() != Some(returned_state) {
|
||||
tracing::warn!(
|
||||
provider,
|
||||
expected_present = expected_state.is_some(),
|
||||
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
|
||||
);
|
||||
return Ok(Redirect::to("/login?error=oauth_state").into_response());
|
||||
}
|
||||
if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
|
||||
tracing::warn!(provider, error = %e, "failed to remove oauth_state from session");
|
||||
}
|
||||
|
||||
let config = match provider {
|
||||
"google" => state
|
||||
.google_config
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?,
|
||||
_ => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let user_info = exchange_fn(state, config, code.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(provider, error = %e, "failed to exchange OAuth code");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let bind_mode: bool = match session.get::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
||||
Ok(v) => v.unwrap_or(false),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
provider,
|
||||
error = %e,
|
||||
"failed to read oauth_bind_mode from session"
|
||||
);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
if bind_mode {
|
||||
let user_id = current_user_id(session)
|
||||
.await
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
if let Err(e) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
||||
tracing::warn!(provider, error = %e, "failed to remove oauth_bind_mode from session after bind");
|
||||
}
|
||||
|
||||
let profile = OAuthProfile {
|
||||
provider: user_info.provider,
|
||||
provider_id: user_info.provider_id,
|
||||
email: user_info.email,
|
||||
name: user_info.name,
|
||||
avatar_url: user_info.avatar_url,
|
||||
};
|
||||
|
||||
bind_oauth_account(&state.pool, user_id, profile)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to bind OAuth account");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
return Ok(Redirect::to("/dashboard?bound=1").into_response());
|
||||
}
|
||||
|
||||
let profile = OAuthProfile {
|
||||
provider: user_info.provider,
|
||||
provider_id: user_info.provider_id,
|
||||
email: user_info.email,
|
||||
name: user_info.name,
|
||||
avatar_url: user_info.avatar_url,
|
||||
};
|
||||
|
||||
let (user, _is_new) = find_or_create_user(&state.pool, profile)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to find or create user");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
session
|
||||
.insert(SESSION_USER_ID, user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
user_id = %user.id,
|
||||
"failed to insert user_id into session after OAuth"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
session
|
||||
.insert(SESSION_LOGIN_PROVIDER, &provider)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
provider,
|
||||
error = %e,
|
||||
"failed to insert login_provider into session after OAuth"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if let Err(e) = session.insert(SESSION_KEY_VERSION, user.key_version).await {
|
||||
tracing::warn!(error = %e, user_id = %user.id, "failed to insert key_version into session after OAuth");
|
||||
}
|
||||
|
||||
log_login(
|
||||
&state.pool,
|
||||
"oauth",
|
||||
provider,
|
||||
user.id,
|
||||
client_ip,
|
||||
user_agent,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Redirect::to("/dashboard").into_response())
|
||||
}
|
||||
|
||||
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn auth_logout(session: Session) -> impl IntoResponse {
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush session on logout");
|
||||
}
|
||||
Redirect::to("/")
|
||||
}
|
||||
|
||||
// ── Account bind/unbind ───────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn account_bind_google(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let _ = current_user_id(&session)
|
||||
.await
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
session
|
||||
.insert(SESSION_OAUTH_BIND_MODE, true)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to insert oauth_bind_mode into session");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let oauth_state = random_state();
|
||||
if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await {
|
||||
tracing::error!(error = %e, "failed to insert oauth_state for account bind flow");
|
||||
if let Err(rm) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
||||
tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure");
|
||||
}
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
let url = google_auth_url(config, &oauth_state);
|
||||
Ok(Redirect::to(&url).into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn account_unbind(
|
||||
State(state): State<AppState>,
|
||||
Path(provider): Path<String>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user_id = current_user_id(&session)
|
||||
.await
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let current_login_provider = session
|
||||
.get::<String>(SESSION_LOGIN_PROVIDER)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to read login_provider from session");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
unbind_oauth_account(
|
||||
&state.pool,
|
||||
user_id,
|
||||
&provider,
|
||||
current_login_provider.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "failed to unbind oauth account");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to("/dashboard?unbound=1").into_response())
|
||||
}
|
||||
48
crates/secrets-mcp/src/web/changelog.rs
Normal file
48
crates/secrets-mcp/src/web/changelog.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use askama::Template;
|
||||
use axum::{extract::State, http::StatusCode, response::Response};
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::render_template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "changelog.html")]
|
||||
pub(super) struct ChangelogTemplate {
|
||||
pub base_url: String,
|
||||
pub version: &'static str,
|
||||
pub changelog_html: String,
|
||||
}
|
||||
|
||||
fn markdown_to_html(md: &str) -> String {
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
let parser = Parser::new_ext(md, opts);
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, parser);
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) async fn changelog_page(State(state): State<AppState>) -> Result<Response, StatusCode> {
|
||||
let md = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"));
|
||||
render_template(ChangelogTemplate {
|
||||
base_url: state.base_url.clone(),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
changelog_html: markdown_to_html(md),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::markdown_to_html;
|
||||
|
||||
#[test]
|
||||
fn markdown_renders_heading_and_list() {
|
||||
let html = markdown_to_html("# Title\n\n- a\n");
|
||||
assert!(html.contains("<h1"));
|
||||
assert!(html.contains("Title"));
|
||||
assert!(html.contains("<ul") || html.contains("<li"));
|
||||
}
|
||||
}
|
||||
1360
crates/secrets-mcp/src/web/entries.rs
Normal file
1360
crates/secrets-mcp/src/web/entries.rs
Normal file
File diff suppressed because it is too large
Load Diff
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
@@ -0,0 +1,894 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use secrets_core::crypto::hex;
|
||||
use secrets_core::service::api_key::validate_api_key;
|
||||
use secrets_core::service::delete::{DeleteParams, run as svc_delete};
|
||||
use secrets_core::service::get_secret::get_all_secrets_by_id;
|
||||
use secrets_core::service::history::run as svc_history;
|
||||
use secrets_core::service::relations::get_relations_for_entries;
|
||||
use secrets_core::service::search::{
|
||||
SearchParams, count_entries, resolve_entry_by_id, run as svc_search,
|
||||
};
|
||||
use secrets_core::service::user::get_user_by_id;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{
|
||||
UiLang, render_template, request_ui_lang, require_valid_user, require_valid_user_json,
|
||||
};
|
||||
|
||||
const BIND_TTL_SECS: u64 = 600;
|
||||
|
||||
#[derive(Clone, sqlx::FromRow)]
|
||||
struct BindRow {
|
||||
device_code: String,
|
||||
user_id: Option<Uuid>,
|
||||
approved: bool,
|
||||
}
|
||||
|
||||
enum ConsumeBindOutcome {
|
||||
Pending,
|
||||
Ready(BindRow),
|
||||
NotFound,
|
||||
DeviceMismatch,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct BindStartOutput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approve_url: String,
|
||||
expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindApproveInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindExchangeInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(
|
||||
source = r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>Local MCP 绑定确认</title></head>
|
||||
<body>
|
||||
<h1>确认绑定本地 MCP</h1>
|
||||
{% if error.is_some() %}
|
||||
<p style="color:#c00">{{ error.as_ref().unwrap() }}</p>
|
||||
{% endif %}
|
||||
{% if approved %}
|
||||
<p>绑定已确认。你可以返回本地页面继续下一步。</p>
|
||||
{% else %}
|
||||
<p>Bind ID: <code>{{ bind_id }}</code></p>
|
||||
<form method="post" action="/api/local-mcp/bind/approve">
|
||||
<input type="hidden" name="bind_id" value="{{ bind_id }}"/>
|
||||
<input type="hidden" name="device_code" value="{{ device_code }}"/>
|
||||
<button type="submit">确认绑定</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>"#,
|
||||
ext = "html"
|
||||
)]
|
||||
struct ApproveTemplate {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approved: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn cleanup_expired(pool: &PgPool) {
|
||||
let _ = sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE expires_at <= NOW()")
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn fetch_bind(pool: &PgPool, bind_id: &str) -> Result<Option<BindRow>, StatusCode> {
|
||||
sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to fetch local MCP bind");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
}
|
||||
|
||||
async fn require_user_from_bearer(pool: &PgPool, headers: &HeaderMap) -> Result<Uuid, StatusCode> {
|
||||
let auth_header = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let raw_key = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
validate_api_key(pool, raw_key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to validate api key for local MCP refresh");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
async fn consume_bind_session(
|
||||
pool: &PgPool,
|
||||
bind_id: &str,
|
||||
device_code: &str,
|
||||
) -> Result<ConsumeBindOutcome, (StatusCode, Json<serde_json::Value>)> {
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to start tx for bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to start bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
let stored = sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to lock bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)
|
||||
})?;
|
||||
let Some(bind) = stored else {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::NotFound);
|
||||
};
|
||||
if bind.device_code != device_code {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::DeviceMismatch);
|
||||
}
|
||||
if !bind.approved {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::Pending);
|
||||
}
|
||||
sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE bind_id = $1")
|
||||
.bind(bind_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to consume bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to consume bind session" })),
|
||||
)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to commit bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to commit bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
Ok(ConsumeBindOutcome::Ready(bind))
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_start(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<BindStartOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
let bind_id = Uuid::new_v4().to_string();
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, expires_at)
|
||||
VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 second'))",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(BIND_TTL_SECS as i64)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to insert local MCP bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to create bind session" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let approve_url = format!(
|
||||
"{}/local-mcp/approve?bind_id={}&device_code={}",
|
||||
state.base_url, bind_id, device_code
|
||||
);
|
||||
|
||||
Ok(Json(BindStartOutput {
|
||||
bind_id,
|
||||
device_code,
|
||||
approve_url,
|
||||
expires_in_secs: BIND_TTL_SECS,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ApproveQuery {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
pub(super) async fn approve_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<ApproveQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let _user = require_valid_user(&state.pool, &session, "local_mcp.approve_page").await?;
|
||||
|
||||
cleanup_expired(&state.pool).await;
|
||||
let mut approved = false;
|
||||
let mut error = None;
|
||||
|
||||
match fetch_bind(&state.pool, &query.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == query.device_code => approved = bind.approved,
|
||||
Ok(Some(_)) => error = Some("device_code 不匹配".to_string()),
|
||||
Ok(None) => error = Some("绑定已过期或不存在".to_string()),
|
||||
Err(status) => return Err(status.into_response()),
|
||||
}
|
||||
|
||||
render_template(ApproveTemplate {
|
||||
bind_id: query.bind_id,
|
||||
device_code: query.device_code,
|
||||
approved,
|
||||
error,
|
||||
})
|
||||
.map_err(|status| status.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_approve(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Form(input): axum::Form<BindApproveInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let lang: UiLang = request_ui_lang(&headers);
|
||||
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
match fetch_bind(&state.pool, &input.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == input.device_code => {
|
||||
sqlx::query(
|
||||
"UPDATE local_mcp_bind_sessions
|
||||
SET user_id = $1, approved = TRUE
|
||||
WHERE bind_id = $2 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&input.bind_id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id = %input.bind_id, "failed to approve bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to approve bind session" })),
|
||||
)
|
||||
})?;
|
||||
Ok(axum::response::Redirect::to(&format!(
|
||||
"/local-mcp/approve?bind_id={}&device_code={}&approved=1",
|
||||
input.bind_id, input.device_code
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
Ok(Some(_)) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
)),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
)),
|
||||
Err(status) => Err((
|
||||
status,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<BindExchangeInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
let bind = match consume_bind_session(&state.pool, &input.bind_id, &input.device_code).await? {
|
||||
ConsumeBindOutcome::Pending => {
|
||||
return Ok((StatusCode::ACCEPTED, Json(json!({ "status": "pending" }))).into_response());
|
||||
}
|
||||
ConsumeBindOutcome::NotFound => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::DeviceMismatch => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::Ready(bind) => bind,
|
||||
};
|
||||
let user_id = bind.user_id.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "approved bind missing user_id" })),
|
||||
)
|
||||
})?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let key_salt_hex = user.key_salt.as_ref().map(|bytes| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>()
|
||||
});
|
||||
let key_check_hex = user.key_check.as_deref().map(hex::encode_hex);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"api_key": user.api_key,
|
||||
"key_salt_hex": key_salt_hex,
|
||||
"key_check_hex": key_check_hex,
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use secrets_core::{
|
||||
config::resolve_db_config,
|
||||
db::{create_pool, migrate},
|
||||
};
|
||||
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let config = resolve_db_config("").ok()?;
|
||||
let pool = create_pool(&config).await.ok()?;
|
||||
migrate(&pool).await.ok()?;
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_bind_session_is_single_use() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let bind_id = format!("test-{}", Uuid::new_v4());
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
let user_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, user_id, approved, expires_at)
|
||||
VALUES ($1, $2, $3, TRUE, NOW() + INTERVAL '10 minutes')",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(first, ConsumeBindOutcome::Ready(_)));
|
||||
let second = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(second, ConsumeBindOutcome::NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"user_id": user.id,
|
||||
"key_salt_hex": user.key_salt.as_deref().map(hex::encode_hex),
|
||||
"key_check_hex": user.key_check.as_deref().map(hex::encode_hex),
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalSearchInput {
|
||||
query: Option<String>,
|
||||
metadata_query: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
name: Option<String>,
|
||||
name_query: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
summary: Option<bool>,
|
||||
sort: Option<String>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalHistoryInput {
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
id: Option<Uuid>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalDeleteInput {
|
||||
id: Option<Uuid>,
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
fn require_encryption_key_local(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<[u8; 32], (StatusCode, Json<serde_json::Value>)> {
|
||||
let enc_key_hex = headers
|
||||
.get("x-encryption-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "Missing X-Encryption-Key header" })),
|
||||
)
|
||||
})?;
|
||||
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "Invalid X-Encryption-Key format" })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_entry_json(
|
||||
entry: &secrets_core::models::Entry,
|
||||
relations: secrets_core::service::relations::EntryRelations,
|
||||
secret_fields: &[secrets_core::models::SecretField],
|
||||
summary: bool,
|
||||
) -> Value {
|
||||
if summary {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"tags": entry.tags,
|
||||
"notes": entry.notes,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
} else {
|
||||
let schema: Vec<_> = secret_fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
json!({
|
||||
"id": field.id,
|
||||
"name": field.name,
|
||||
"type": field.secret_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"notes": entry.notes,
|
||||
"tags": entry.tags,
|
||||
"metadata": entry.metadata,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"secret_fields": schema,
|
||||
"version": entry.version,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_find(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find failed" })),
|
||||
)
|
||||
})?;
|
||||
let total_count = count_entries(
|
||||
&state.pool,
|
||||
&SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find count failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find count failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, false)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"total_count": total_count,
|
||||
"entries": entries,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_search(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: input.sort.as_deref().unwrap_or("name"),
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let summary = input.summary.unwrap_or(false);
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, summary)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(Value::Array(entries)))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalHistoryInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let (name, folder) = if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp history missing entry");
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
)
|
||||
})?;
|
||||
(entry.name, Some(entry.folder))
|
||||
} else {
|
||||
let name = input.name.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "name or id is required" })),
|
||||
)
|
||||
})?;
|
||||
(name, input.folder)
|
||||
};
|
||||
let result = svc_history(
|
||||
&state.pool,
|
||||
&name,
|
||||
folder.as_deref(),
|
||||
input.limit.unwrap_or(20),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, name = %name, "local mcp history failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!([])),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_overview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CountRow {
|
||||
name: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let folder_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT folder AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY folder ORDER BY folder",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview folders failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let type_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT type AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY type ORDER BY type",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview types failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let total: i64 = folder_rows.iter().map(|row| row.count).sum();
|
||||
Ok(Json(json!({
|
||||
"total": total,
|
||||
"folders": folder_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
"types": type_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_delete_preview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalDeleteInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
if !input.dry_run.unwrap_or(false) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "dry_run=true is required" })),
|
||||
));
|
||||
}
|
||||
let (effective_name, effective_folder) =
|
||||
if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp delete preview missing entry");
|
||||
(StatusCode::NOT_FOUND, Json(json!({ "error": "entry not found" })))
|
||||
})?;
|
||||
(Some(entry.name), Some(entry.folder))
|
||||
} else {
|
||||
(input.name, input.folder)
|
||||
};
|
||||
let result = svc_delete(
|
||||
&state.pool,
|
||||
DeleteParams {
|
||||
name: effective_name.as_deref(),
|
||||
folder: effective_folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
dry_run: true,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, "local mcp delete preview failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_secrets_decrypt_bearer(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let master_key = require_encryption_key_local(&headers)?;
|
||||
let secrets = get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %entry_id, "local mcp decrypt failed");
|
||||
if let Some(app_err) = e.downcast_ref::<secrets_core::error::AppError>() {
|
||||
return match app_err {
|
||||
secrets_core::error::AppError::DecryptionFailed => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": "Decryption failed, verify passphrase" })),
|
||||
),
|
||||
secrets_core::error::AppError::NotFoundEntry
|
||||
| secrets_core::error::AppError::NotFoundUser
|
||||
| secrets_core::error::AppError::NotFoundSecret => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
),
|
||||
};
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(secrets).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
420
crates/secrets-mcp/src/web/mod.rs
Normal file
420
crates/secrets-mcp/src/web/mod.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, patch, post},
|
||||
};
|
||||
use serde_json::json;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::oauth::OAuthConfig;
|
||||
|
||||
mod account;
|
||||
mod assets;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod changelog;
|
||||
mod entries;
|
||||
mod local_mcp;
|
||||
|
||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
const SESSION_OAUTH_STATE: &str = "oauth_state";
|
||||
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
|
||||
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
|
||||
const SESSION_KEY_VERSION: &str = "key_version";
|
||||
|
||||
// ── Page limits ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cap for HTML list (avoids loading unbounded rows into memory).
|
||||
const ENTRIES_PAGE_LIMIT: u32 = 50;
|
||||
const AUDIT_PAGE_LIMIT: i64 = 10;
|
||||
|
||||
// ── UI language ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum UiLang {
|
||||
ZhCn,
|
||||
ZhTw,
|
||||
En,
|
||||
}
|
||||
|
||||
fn request_ui_lang(headers: &HeaderMap) -> UiLang {
|
||||
let Some(raw) = headers
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return UiLang::ZhCn;
|
||||
};
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
if lower.contains("zh-tw") || lower.contains("zh-hk") || lower.contains("zh-hant") {
|
||||
UiLang::ZhTw
|
||||
} else if lower.contains("zh") {
|
||||
UiLang::ZhCn
|
||||
} else if lower.contains("en") {
|
||||
UiLang::En
|
||||
} else {
|
||||
UiLang::ZhCn
|
||||
}
|
||||
}
|
||||
|
||||
fn tr(lang: UiLang, zh_cn: &'static str, zh_tw: &'static str, en: &'static str) -> &'static str {
|
||||
match lang {
|
||||
UiLang::ZhCn => zh_cn,
|
||||
UiLang::ZhTw => zh_tw,
|
||||
UiLang::En => en,
|
||||
}
|
||||
}
|
||||
|
||||
// ── App state helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
|
||||
state.google_config.as_ref()
|
||||
}
|
||||
|
||||
async fn current_user_id(session: &Session) -> Option<Uuid> {
|
||||
match session.get::<String>(SESSION_USER_ID).await {
|
||||
Ok(opt) => match opt {
|
||||
Some(s) => match Uuid::parse_str(&s) {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, user_id_str = %s, "invalid user_id UUID in session");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read user_id from session");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate the current user from session and DB.
|
||||
///
|
||||
/// Returns the user if the session is valid. Flushes the session and returns
|
||||
/// `Err(Redirect::to("/login"))` when:
|
||||
/// - the session has no `user_id`,
|
||||
/// - the user no longer exists in the database, or
|
||||
/// - the stored `key_version` does not match the DB value (passphrase changed on
|
||||
/// another device since this session was created).
|
||||
async fn require_valid_user(
|
||||
pool: &sqlx::PgPool,
|
||||
session: &Session,
|
||||
context: &str,
|
||||
) -> Result<secrets_core::models::User, Response> {
|
||||
let Some(user_id) = current_user_id(session).await else {
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, %user_id, context, "failed to load user");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush stale session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
Ok(Some(u)) => u,
|
||||
};
|
||||
|
||||
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(kv) = session_kv
|
||||
&& kv != user.key_version
|
||||
{
|
||||
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session");
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush outdated session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// `Ok(None)` — unauthenticated or session invalidated (including `key_version` mismatch).
|
||||
/// `Err(())` — database error loading the user.
|
||||
pub(super) async fn load_session_user_strict(
|
||||
pool: &sqlx::PgPool,
|
||||
session: &Session,
|
||||
) -> Result<Option<secrets_core::models::User>, ()> {
|
||||
let Some(user_id) = current_user_id(session).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, %user_id, "load_session_user_strict: failed to load user");
|
||||
return Err(());
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush stale session");
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(u)) => u,
|
||||
};
|
||||
|
||||
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(kv) = session_kv
|
||||
&& kv != user.key_version
|
||||
{
|
||||
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session (API)");
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush outdated session");
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(user))
|
||||
}
|
||||
|
||||
/// JSON API equivalent of [`require_valid_user`]: returns `401` with a JSON body instead of redirecting.
|
||||
pub(super) async fn require_valid_user_json(
|
||||
pool: &sqlx::PgPool,
|
||||
session: &Session,
|
||||
lang: UiLang,
|
||||
) -> Result<secrets_core::models::User, (StatusCode, Json<serde_json::Value>)> {
|
||||
match load_session_user_strict(pool, session).await {
|
||||
Ok(Some(user)) => Ok(user),
|
||||
Ok(None) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
)),
|
||||
Err(()) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn paginate(page: u32, total_count: i64, page_size: u32) -> (u32, u32, u32) {
|
||||
let page_size = page_size.max(1);
|
||||
let safe_total_count = u32::try_from(total_count.max(0)).unwrap_or(u32::MAX);
|
||||
let total_pages = safe_total_count.div_ceil(page_size).max(1);
|
||||
let current_page = page.max(1).min(total_pages);
|
||||
let offset = (current_page - 1).saturating_mul(page_size);
|
||||
(current_page, total_pages, offset)
|
||||
}
|
||||
|
||||
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
|
||||
let html = tmpl.render().map_err(|e| {
|
||||
tracing::error!(error = %e, "template render error");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn web_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/robots.txt", get(assets::robots_txt))
|
||||
.route("/llms.txt", get(assets::llms_txt))
|
||||
.route("/ai.txt", get(assets::ai_txt))
|
||||
.route("/static/i18n.js", get(assets::i18n_js))
|
||||
.route("/favicon.svg", get(assets::favicon_svg))
|
||||
.route(
|
||||
"/favicon.ico",
|
||||
get(|| async { Redirect::permanent("/favicon.svg") }),
|
||||
)
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
get(assets::oauth_protected_resource_metadata),
|
||||
)
|
||||
.route("/", get(auth::home_page))
|
||||
.route("/changelog", get(changelog::changelog_page))
|
||||
.route("/login", get(auth::login_page))
|
||||
.route("/auth/google", get(auth::auth_google))
|
||||
.route("/auth/google/callback", get(auth::auth_google_callback))
|
||||
.route("/auth/logout", post(auth::auth_logout))
|
||||
.route("/local-mcp/approve", get(local_mcp::approve_page))
|
||||
.route("/dashboard", get(account::dashboard))
|
||||
.route("/entries", get(entries::entries_page))
|
||||
.route("/trash", get(entries::trash_page))
|
||||
.route("/audit", get(audit::audit_page))
|
||||
.route("/account/bind/google", get(auth::account_bind_google))
|
||||
.route("/account/unbind/{provider}", post(auth::account_unbind))
|
||||
.route("/api/key-salt", get(account::api_key_salt))
|
||||
.route("/api/local-mcp/bind/start", post(local_mcp::api_bind_start))
|
||||
.route(
|
||||
"/api/local-mcp/bind/approve",
|
||||
post(local_mcp::api_bind_approve),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/exchange",
|
||||
post(local_mcp::api_bind_exchange),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/refresh",
|
||||
post(local_mcp::api_bind_refresh),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/find",
|
||||
post(local_mcp::api_entries_find),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/search",
|
||||
post(local_mcp::api_entries_search),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/history",
|
||||
post(local_mcp::api_entry_history),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/overview",
|
||||
get(local_mcp::api_entries_overview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/delete-preview",
|
||||
post(local_mcp::api_entries_delete_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/{id}/secrets",
|
||||
get(local_mcp::api_entry_secrets_decrypt_bearer),
|
||||
)
|
||||
.route("/api/key-setup", post(account::api_key_setup))
|
||||
.route("/api/key-change", post(account::api_key_change))
|
||||
.route("/api/apikey", get(account::api_apikey_get))
|
||||
.route("/api/entries/options", get(entries::api_entry_options))
|
||||
.route(
|
||||
"/api/apikey/regenerate",
|
||||
post(account::api_apikey_regenerate),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{id}",
|
||||
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
|
||||
)
|
||||
.route("/api/trash/{id}/restore", post(entries::api_trash_restore))
|
||||
.route(
|
||||
"/api/trash/{id}",
|
||||
axum::routing::delete(entries::api_trash_purge),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||
axum::routing::delete(entries::api_entry_secret_unlink),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{id}/secrets/decrypt",
|
||||
get(entries::api_entry_secrets_decrypt),
|
||||
)
|
||||
.route("/api/secrets/{secret_id}", patch(entries::api_secret_patch))
|
||||
.route(
|
||||
"/api/secrets/check-name",
|
||||
get(entries::api_secret_check_name),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_ip_ignores_forwarded_headers_without_trusted_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.10".parse().unwrap());
|
||||
|
||||
let ip = crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
SocketAddr::from(([127, 0, 0, 1], 9315)),
|
||||
);
|
||||
|
||||
assert_eq!(ip, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_uses_valid_forwarded_header_with_trusted_proxy() {
|
||||
// This test relies on TRUST_PROXY being unset (default); skip if set in env
|
||||
if std::env::var("TRUST_PROXY").is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.10, 10.0.0.1".parse().unwrap());
|
||||
|
||||
// Direct connection IP is used when TRUST_PROXY is not set
|
||||
let ip = crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
SocketAddr::from(([127, 0, 0, 1], 9315)),
|
||||
);
|
||||
|
||||
assert_eq!(ip, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_ui_lang_prefers_zh_cn_over_en_fallback() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_LANGUAGE, "zh-CN, en;q=0.5".parse().unwrap());
|
||||
|
||||
assert!(matches!(request_ui_lang(&headers), UiLang::ZhCn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_ui_lang_detects_traditional_chinese_variants() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::ACCEPT_LANGUAGE,
|
||||
"zh-Hant, en;q=0.5".parse().unwrap(),
|
||||
);
|
||||
|
||||
assert!(matches!(request_ui_lang(&headers), UiLang::ZhTw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_clamps_page_before_computing_offset() {
|
||||
let (current_page, total_pages, offset) = paginate(100, 12, 10);
|
||||
|
||||
assert_eq!(current_page, 2);
|
||||
assert_eq!(total_pages, 2);
|
||||
assert_eq!(offset, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_handles_large_page_without_overflow() {
|
||||
let (current_page, total_pages, offset) = paginate(u32::MAX, 1, ENTRIES_PAGE_LIMIT);
|
||||
|
||||
assert_eq!(current_page, 1);
|
||||
assert_eq!(total_pages, 1);
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_saturates_large_total_count() {
|
||||
let (_, total_pages, _) = paginate(1, i64::MAX, ENTRIES_PAGE_LIMIT);
|
||||
|
||||
assert_eq!(total_pages, u32::MAX.div_ceil(ENTRIES_PAGE_LIMIT));
|
||||
}
|
||||
}
|
||||
@@ -13,56 +13,87 @@
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
body { background: #0d1117; color: #c9d1d9; font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active {
|
||||
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
|
||||
background: rgba(56,139,253,0.14); color: #fff;
|
||||
}
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { background: var(--surface2); }
|
||||
.main { padding: 32px 24px 40px; flex: 1; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
|
||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
||||
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
||||
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.main { padding: 16px 16px 24px; flex: 1; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title-row {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card-title { font-size: 22px; font-weight: 700; margin: 0; color: #fff; }
|
||||
.card-title-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 999px;
|
||||
background: #0d1117;
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
||||
th { color: #8b949e; font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; color: #c9d1d9; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.detail {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
|
||||
max-width: 460px;
|
||||
.col-detail { min-width: 260px; max-width: 460px; }
|
||||
.detail-scroll {
|
||||
height: calc(1.5em * 3 + 20px);
|
||||
min-height: calc(1.5em * 3 + 20px);
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; }
|
||||
@@ -72,28 +103,44 @@
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
thead { display: none; }
|
||||
tr { border-top: 1px solid var(--border); padding: 12px 0; }
|
||||
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
|
||||
td { border-top: none; padding: 6px 0; }
|
||||
td::before {
|
||||
display: block; color: var(--text-muted); font-size: 11px;
|
||||
display: block; color: #8b949e; font-size: 11px;
|
||||
margin-bottom: 4px; text-transform: uppercase;
|
||||
content: attr(data-label);
|
||||
}
|
||||
td.col-time::before { content: "Time"; }
|
||||
td.col-action::before { content: "Action"; }
|
||||
td.col-target::before { content: "Target"; }
|
||||
td.col-detail::before { content: "Detail"; }
|
||||
.detail { max-width: none; }
|
||||
}
|
||||
.pagination {
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 18px;
|
||||
justify-content: center; padding: 12px 0;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.page-btn-disabled {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #6e7681; font-size: 13px;
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.page-info {
|
||||
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link">MCP</a>
|
||||
<a href="/entries" class="sidebar-link">条目</a>
|
||||
<a href="/audit" class="sidebar-link active">审计</a>
|
||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link active" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -101,46 +148,89 @@
|
||||
<div class="topbar">
|
||||
<span class="topbar-spacer"></span>
|
||||
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
|
||||
<div class="lang-bar">
|
||||
<button class="lang-btn" onclick="setLang('zh-CN')">简</button>
|
||||
<button class="lang-btn" onclick="setLang('zh-TW')">繁</button>
|
||||
<button class="lang-btn" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
<form action="/auth/logout" method="post" style="display:inline">
|
||||
<button type="submit" class="btn-sign-out">退出</button>
|
||||
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div class="card-title">我的审计</div>
|
||||
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div>
|
||||
<div class="card-title-row">
|
||||
<div class="card-title" data-i18n="auditTitle">我的审计</div>
|
||||
<span class="card-title-count">{{ total_count }}</span>
|
||||
</div>
|
||||
|
||||
{% if entries.is_empty() %}
|
||||
<div class="empty">暂无审计记录。</div>
|
||||
<div class="empty" data-i18n="emptyAudit">暂无审计记录。</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>动作</th>
|
||||
<th>目标</th>
|
||||
<th>详情</th>
|
||||
<th data-i18n="colTime">时间</th>
|
||||
<th data-i18n="colAction">动作</th>
|
||||
<th data-i18n="colTarget">目标</th>
|
||||
<th data-i18n="colDetail">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td class="col-time mono"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
|
||||
<td class="col-action mono">{{ entry.action }}</td>
|
||||
<td class="col-target mono">{{ entry.target }}</td>
|
||||
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
|
||||
<td class="col-time mono" data-label="时间"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
|
||||
<td class="col-action mono" data-label="动作">{{ entry.action }}</td>
|
||||
<td class="col-target mono" data-label="目标">{{ entry.target }}</td>
|
||||
<td class="col-detail" data-label="详情">{% if !entry.detail.is_empty() %}<pre class="detail-scroll">{{ entry.detail }}</pre>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
I18N_PAGE = {
|
||||
'zh-CN': { pageTitle: 'Secrets — 审计', auditTitle: '我的审计', emptyAudit: '暂无审计记录。', colTime: '时间', colAction: '动作', colTarget: '目标', colDetail: '详情', prevPage: '上一页', nextPage: '下一页' },
|
||||
'zh-TW': { pageTitle: 'Secrets — 審計', auditTitle: '我的審計', emptyAudit: '暫無審計記錄。', colTime: '時間', colAction: '動作', colTarget: '目標', colDetail: '詳情', prevPage: '上一頁', nextPage: '下一頁' },
|
||||
en: { pageTitle: 'Secrets — Audit', auditTitle: 'My audit', emptyAudit: 'No audit records.', colTime: 'Time', colAction: 'Action', colTarget: 'Target', colDetail: 'Detail', prevPage: 'Previous', nextPage: 'Next' }
|
||||
};
|
||||
|
||||
window.applyPageLang = function () {
|
||||
document.querySelectorAll('tbody tr').forEach(function (tr) {
|
||||
var time = tr.querySelector('.col-time');
|
||||
var action = tr.querySelector('.col-action');
|
||||
var target = tr.querySelector('.col-target');
|
||||
var detail = tr.querySelector('.col-detail');
|
||||
if (time) time.setAttribute('data-label', t('mobileLabelTime'));
|
||||
if (action) action.setAttribute('data-label', t('mobileLabelAction'));
|
||||
if (target) target.setAttribute('data-label', t('mobileLabelTarget'));
|
||||
if (detail) detail.setAttribute('data-label', t('mobileLabelDetail'));
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
|
||||
var raw = el.getAttribute('datetime');
|
||||
var d = raw ? new Date(raw) : null;
|
||||
@@ -149,6 +239,8 @@
|
||||
el.title = raw + ' (UTC)';
|
||||
}
|
||||
});
|
||||
|
||||
applyLang();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
185
crates/secrets-mcp/templates/changelog.html
Normal file
185
crates/secrets-mcp/templates/changelog.html
Normal file
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="canonical" href="{{ base_url }}/changelog">
|
||||
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
|
||||
<title data-i18n="docTitle">变更记录 — Secrets</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
|
||||
:root {
|
||||
--bg: #0d1117; --surface: #161b22;
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.wrap { max-width: 880px; margin: 0 auto; padding: 24px 20px 48px; }
|
||||
.top {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px;
|
||||
margin-bottom: 24px; padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
}
|
||||
.brand {
|
||||
font-size: 18px; font-weight: 700; color: #fff; text-decoration: none;
|
||||
}
|
||||
.brand:hover { color: var(--accent); }
|
||||
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.link-dash {
|
||||
font-size: 13px; color: var(--accent); text-decoration: none;
|
||||
}
|
||||
.link-dash:hover { text-decoration: underline; }
|
||||
h1 { font-size: 22px; font-weight: 700; margin-bottom: 16px; color: #fff; }
|
||||
.card {
|
||||
background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
/* Rendered Markdown (pulldown-cmark) */
|
||||
.changelog-md {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.changelog-md > :first-child { margin-top: 0; }
|
||||
.changelog-md > :last-child { margin-bottom: 0; }
|
||||
.changelog-md h1 {
|
||||
font-size: 1.5rem; font-weight: 700; color: #fff;
|
||||
margin: 1.25em 0 0.5em; padding-bottom: 0.35em;
|
||||
border-bottom: 1px solid rgba(240,246,252,0.1);
|
||||
}
|
||||
.changelog-md h2 {
|
||||
font-size: 1.2rem; font-weight: 650; color: #f0f6fc;
|
||||
margin: 1.35em 0 0.5em;
|
||||
}
|
||||
.changelog-md h3 { font-size: 1.05rem; font-weight: 600; color: #e6edf3; margin: 1.1em 0 0.45em; }
|
||||
.changelog-md h4, .changelog-md h5, .changelog-md h6 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 1em 0 0.4em; }
|
||||
.changelog-md p { margin: 0.65em 0; }
|
||||
.changelog-md ul, .changelog-md ol { margin: 0.65em 0; padding-left: 1.35em; }
|
||||
.changelog-md li { margin: 0.3em 0; }
|
||||
.changelog-md li > p { margin: 0.35em 0; }
|
||||
.changelog-md a { color: var(--accent); text-decoration: none; }
|
||||
.changelog-md a:hover { text-decoration: underline; }
|
||||
.changelog-md code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
background: rgba(240,246,252,0.08);
|
||||
padding: 0.12em 0.4em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.changelog-md pre {
|
||||
margin: 0.85em 0;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(240,246,252,0.1);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.changelog-md pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
.changelog-md blockquote {
|
||||
margin: 0.75em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid rgba(56,139,253,0.45);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.changelog-md hr {
|
||||
margin: 1.25em 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(240,246,252,0.1);
|
||||
}
|
||||
.changelog-md table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.85em 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.changelog-md th, .changelog-md td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.changelog-md th { background: rgba(240,246,252,0.06); color: #f0f6fc; }
|
||||
.changelog-md input[type="checkbox"] { margin-right: 0.35em; vertical-align: middle; }
|
||||
.foot {
|
||||
margin-top: 28px; text-align: center; font-size: 11px; color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.foot a { color: var(--accent); text-decoration: none; }
|
||||
.foot a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<a href="/" class="brand">secrets</a>
|
||||
<div class="top-actions">
|
||||
<a href="/dashboard" class="link-dash" data-i18n="backDash">控制台</a>
|
||||
<div class="lang-bar" role="group" aria-label="Language">
|
||||
<button type="button" class="lang-btn" onclick="setLang('zh-CN')">简</button>
|
||||
<button type="button" class="lang-btn" onclick="setLang('zh-TW')">繁</button>
|
||||
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<h1 data-i18n="pageTitle">变更记录</h1>
|
||||
<div class="card changelog-md">
|
||||
{{ changelog_html|safe }}
|
||||
</div>
|
||||
<footer class="foot">
|
||||
<span data-i18n="versionLabel">版本</span> {{ version }}
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
const T = {
|
||||
'zh-CN': {
|
||||
docTitle: '变更记录 — Secrets',
|
||||
pageTitle: '变更记录',
|
||||
backDash: '控制台',
|
||||
versionLabel: '版本',
|
||||
},
|
||||
'zh-TW': {
|
||||
docTitle: '變更記錄 — Secrets',
|
||||
pageTitle: '變更記錄',
|
||||
backDash: '控制台',
|
||||
versionLabel: '版本',
|
||||
},
|
||||
'en': {
|
||||
docTitle: 'Changelog — Secrets',
|
||||
pageTitle: 'Changelog',
|
||||
backDash: 'Dashboard',
|
||||
versionLabel: 'Version',
|
||||
}
|
||||
};
|
||||
let currentLang = localStorage.getItem('lang') || 'zh-CN';
|
||||
function t(key) { return (T[currentLang] && T[currentLang][key]) || T['en'][key] || key; }
|
||||
function applyLang() {
|
||||
document.documentElement.lang = currentLang;
|
||||
document.title = t('docTitle');
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = t(el.getAttribute('data-i18n'));
|
||||
});
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
|
||||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
||||
});
|
||||
}
|
||||
function setLang(lang) {
|
||||
currentLang = lang;
|
||||
localStorage.setItem('lang', lang);
|
||||
applyLang();
|
||||
}
|
||||
applyLang();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,110 +18,110 @@
|
||||
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
|
||||
.sidebar-link.active {
|
||||
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
||||
.btn-sign-out { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
|
||||
.btn-sign-out:hover { background: var(--surface2); }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
|
||||
/* Main content column */
|
||||
.main { display: flex; flex-direction: column; align-items: center;
|
||||
padding: 24px 20px 8px; min-height: 0; }
|
||||
.main { padding: 16px 16px 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.app-footer {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
padding: 4px 20px 12px;
|
||||
font-size: 12px;
|
||||
color: #9da7b3;
|
||||
padding: 12px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin-top: auto;
|
||||
}
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 100%; max-width: 980px; }
|
||||
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
|
||||
.app-footer a { color: var(--accent); text-decoration: none; }
|
||||
.app-footer a:hover { text-decoration: underline; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
|
||||
/* Form */
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
||||
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 9px 12px; border-radius: 6px;
|
||||
.field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
|
||||
.field input { width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||
color: #c9d1d9; padding: 9px 12px; border-radius: 10px;
|
||||
font-size: 13px; outline: none; }
|
||||
.field input:focus { border-color: var(--accent); }
|
||||
.field input:focus { border-color: rgba(56,139,253,0.5); }
|
||||
.pw-field { position: relative; }
|
||||
.pw-field > input { padding-right: 42px; }
|
||||
.pw-toggle {
|
||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border: none; border-radius: 6px;
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
width: 32px; height: 32px; border: none; border-radius: 8px;
|
||||
background: transparent; color: #8b949e; cursor: pointer;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
|
||||
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.pw-toggle:hover { color: #c9d1d9; background: rgba(240,246,252,0.06); }
|
||||
.pw-toggle:focus-visible { outline: 2px solid rgba(56,139,253,0.5); outline-offset: 2px; }
|
||||
.pw-icon svg { display: block; }
|
||||
.pw-icon.hidden { display: none; }
|
||||
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
|
||||
.error-msg { color: #f85149; font-size: 12px; margin-top: 6px; display: none; }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
|
||||
justify-content: center; padding: 10px 20px; border-radius: 7px;
|
||||
border: none; background: var(--accent); color: #0d1117;
|
||||
justify-content: center; padding: 10px 20px; border-radius: 10px;
|
||||
border: none; background: #388bfd; color: #fff;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:hover { background: #58a6ff; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
|
||||
border-radius: 5px; border: 1px solid var(--border); background: none;
|
||||
color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
||||
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px;
|
||||
border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: #161b22;
|
||||
color: #8b949e; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-sm:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
|
||||
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
|
||||
background: rgba(63,185,80,0.1); color: var(--success);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||||
padding: 11px 20px; border-radius: 10px; border: 1px solid #3fb950;
|
||||
background: rgba(63,185,80,0.1); color: #3fb950;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; font-family: inherit; }
|
||||
.btn-copy:hover { background: rgba(63,185,80,0.2); }
|
||||
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
|
||||
.btn-copy.copied { background: #3fb950; color: #0d1117; border-color: #3fb950; }
|
||||
|
||||
/* Config format switcher */
|
||||
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--surface2); color: var(--text-muted); cursor: pointer;
|
||||
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.08);
|
||||
background: #161b22; color: #8b949e; cursor: pointer;
|
||||
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
|
||||
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
|
||||
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
|
||||
.config-tab:hover { color: #c9d1d9; border-color: rgba(56,139,253,0.45); transform: translateY(-1px); }
|
||||
.config-tab.active { background: rgba(56,139,253,0.14); color: #fff; border-color: rgba(56,139,253,0.3); }
|
||||
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
|
||||
/* Config box */
|
||||
.config-wrap { position: relative; margin-bottom: 14px; }
|
||||
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
.config-box { background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
|
||||
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
|
||||
line-height: 1.7; color: #c9d1d9; overflow-x: auto; white-space: pre; }
|
||||
.config-box.locked { color: #8b949e; filter: blur(3px); user-select: none;
|
||||
pointer-events: none; }
|
||||
.config-key { color: #79c0ff; }
|
||||
.config-str { color: #a5d6ff; }
|
||||
.config-val { color: var(--accent); }
|
||||
.config-val { color: #58a6ff; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.divider { border: none; border-top: 1px solid rgba(240,246,252,0.08); margin: 20px 0; }
|
||||
|
||||
/* Actions row */
|
||||
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
@@ -135,34 +135,29 @@
|
||||
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
||||
z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal-bd.open { display: flex; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
.modal { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 28px; width: 100%; max-width: 420px; }
|
||||
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||||
.modal h3 { font-size: 18px; font-weight: 700; margin-bottom: 16px; color: #fff; }
|
||||
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
|
||||
background: var(--accent); color: #0d1117; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; }
|
||||
.btn-modal-ok:hover { background: var(--accent-hover); }
|
||||
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.btn-modal-cancel:hover { background: var(--surface2); }
|
||||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 10px; border: none;
|
||||
background: #388bfd; color: #fff; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.btn-modal-ok:hover { background: #58a6ff; }
|
||||
.btn-modal-cancel { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-modal-cancel:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; }
|
||||
.sidebar-link { flex: 1; text-align: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.config-tabs { grid-template-columns: 1fr; }
|
||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
|
||||
.main { padding: 20px 12px 28px; }
|
||||
.card { padding: 16px; }
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
.main { padding: 16px 12px 6px; }
|
||||
.app-footer { padding: 4px 12px 10px; }
|
||||
.card { padding: 18px; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -171,11 +166,12 @@
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link active">MCP</a>
|
||||
<a href="/entries" class="sidebar-link">条目</a>
|
||||
<a href="/audit" class="sidebar-link">审计</a>
|
||||
<a href="/dashboard" class="sidebar-link active" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -293,18 +289,27 @@
|
||||
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<footer class="app-footer">{{ version }}</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="app-footer">{{ version }} · <a href="/changelog" data-i18n="changelogLink">变更记录</a></footer>
|
||||
</div><!-- /main -->
|
||||
</div><!-- /content-shell -->
|
||||
</div><!-- /layout -->
|
||||
|
||||
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
|
||||
<div class="modal-bd" id="change-modal">
|
||||
<div class="modal">
|
||||
<h3 data-i18n="changeTitle">更换密码</h3>
|
||||
<div class="field">
|
||||
<label data-i18n="labelCurrent">当前密码</label>
|
||||
<div class="pw-field">
|
||||
<input type="password" id="change-pass-old" data-i18n-ph="phCurrent" autocomplete="current-password">
|
||||
<button type="button" class="pw-toggle" data-target="change-pass-old" aria-pressed="false"
|
||||
onclick="togglePwVisibility(this)" aria-label="">
|
||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="labelNew">新密码</label>
|
||||
<div class="pw-field">
|
||||
@@ -340,13 +345,16 @@
|
||||
|
||||
const T = {
|
||||
'zh-CN': {
|
||||
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
|
||||
signOut: '退出',
|
||||
lockedTitle: '获取 MCP 配置',
|
||||
labelPassphrase: '加密密码',
|
||||
labelConfirm: '确认密码',
|
||||
labelNew: '新密码',
|
||||
labelCurrent: '当前密码',
|
||||
phPassphrase: '输入密码…',
|
||||
phConfirm: '再次输入…',
|
||||
phCurrent: '输入当前密码…',
|
||||
btnSetup: '设置并获取配置',
|
||||
btnUnlock: '解锁并获取配置',
|
||||
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
|
||||
@@ -354,6 +362,7 @@ const T = {
|
||||
errShort: '密码至少需要 8 个字符。',
|
||||
errMismatch: '两次输入不一致。',
|
||||
errWrong: '密码错误,请重试。',
|
||||
errWrongOld: '当前密码错误,请重试。',
|
||||
unlockedTitle: 'MCP 配置',
|
||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -372,15 +381,19 @@ const T = {
|
||||
regenFailed: '重置失败,请刷新页面重试。',
|
||||
ariaShowPw: '显示密码',
|
||||
ariaHidePw: '隐藏密码',
|
||||
changelogLink: '变更记录',
|
||||
},
|
||||
'zh-TW': {
|
||||
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||
signOut: '登出',
|
||||
lockedTitle: '取得 MCP 設定',
|
||||
labelPassphrase: '加密密碼',
|
||||
labelConfirm: '確認密碼',
|
||||
labelNew: '新密碼',
|
||||
labelCurrent: '目前密碼',
|
||||
phPassphrase: '輸入密碼…',
|
||||
phConfirm: '再次輸入…',
|
||||
phCurrent: '輸入目前密碼…',
|
||||
btnSetup: '設定並取得設定',
|
||||
btnUnlock: '解鎖並取得設定',
|
||||
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
|
||||
@@ -388,6 +401,7 @@ const T = {
|
||||
errShort: '密碼至少需要 8 個字元。',
|
||||
errMismatch: '兩次輸入不一致。',
|
||||
errWrong: '密碼錯誤,請重試。',
|
||||
errWrongOld: '目前密碼錯誤,請重試。',
|
||||
unlockedTitle: 'MCP 設定',
|
||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -406,15 +420,19 @@ const T = {
|
||||
regenFailed: '重置失敗,請重新整理頁面再試。',
|
||||
ariaShowPw: '顯示密碼',
|
||||
ariaHidePw: '隱藏密碼',
|
||||
changelogLink: '變更記錄',
|
||||
},
|
||||
'en': {
|
||||
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
||||
signOut: 'Sign out',
|
||||
lockedTitle: 'Get MCP Config',
|
||||
labelPassphrase: 'Encryption password',
|
||||
labelConfirm: 'Confirm password',
|
||||
labelNew: 'New password',
|
||||
labelCurrent: 'Current password',
|
||||
phPassphrase: 'Enter password…',
|
||||
phConfirm: 'Repeat password…',
|
||||
phCurrent: 'Enter current password…',
|
||||
btnSetup: 'Set up & get config',
|
||||
btnUnlock: 'Unlock & get config',
|
||||
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
|
||||
@@ -422,6 +440,7 @@ const T = {
|
||||
errShort: 'Password must be at least 8 characters.',
|
||||
errMismatch: 'Passwords do not match.',
|
||||
errWrong: 'Incorrect password, please try again.',
|
||||
errWrongOld: 'Current password is incorrect, please try again.',
|
||||
unlockedTitle: 'MCP Config',
|
||||
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -440,6 +459,7 @@ const T = {
|
||||
regenFailed: 'Reset failed. Please refresh and try again.',
|
||||
ariaShowPw: 'Show password',
|
||||
ariaHidePw: 'Hide password',
|
||||
changelogLink: 'Changelog',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -557,20 +577,16 @@ function buildSecretsConfigText(apiKey, encKey) {
|
||||
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
||||
}
|
||||
|
||||
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
|
||||
/** OpenCode: native Streamable HTTP transport (no mcp-remote bridge needed). */
|
||||
function buildOpencodeEntry(apiKey, encKey) {
|
||||
return {
|
||||
type: 'local',
|
||||
command: [
|
||||
'npx', '-y', 'mcp-remote',
|
||||
BASE_URL + '/mcp',
|
||||
'--header',
|
||||
'Authorization: Bearer ' + apiKey,
|
||||
'--header',
|
||||
'X-Encryption-Key: ' + encKey,
|
||||
'--transport',
|
||||
'http-only'
|
||||
]
|
||||
type: 'remote',
|
||||
url: BASE_URL + '/mcp',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + apiKey,
|
||||
'X-Encryption-Key': encKey
|
||||
},
|
||||
oauth: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -832,14 +848,16 @@ async function confirmRegenerate() {
|
||||
// ── Change passphrase modal ────────────────────────────────────────────────────
|
||||
|
||||
function openChangeModal() {
|
||||
document.getElementById('change-pass-old').value = '';
|
||||
document.getElementById('change-pass1').value = '';
|
||||
document.getElementById('change-pass2').value = '';
|
||||
document.getElementById('change-pass-old').type = 'password';
|
||||
document.getElementById('change-pass1').type = 'password';
|
||||
document.getElementById('change-pass2').type = 'password';
|
||||
document.getElementById('change-error').style.display = 'none';
|
||||
document.getElementById('change-modal').classList.add('open');
|
||||
syncPwToggleI18n();
|
||||
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
|
||||
setTimeout(() => document.getElementById('change-pass-old').focus(), 50);
|
||||
}
|
||||
|
||||
function closeChangeModal() {
|
||||
@@ -847,11 +865,13 @@ function closeChangeModal() {
|
||||
}
|
||||
|
||||
async function doChange() {
|
||||
const passOld = document.getElementById('change-pass-old').value;
|
||||
const pass1 = document.getElementById('change-pass1').value;
|
||||
const pass2 = document.getElementById('change-pass2').value;
|
||||
const errEl = document.getElementById('change-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
if (!passOld) { showErr(errEl, t('errEmpty')); return; }
|
||||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
||||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
||||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
||||
@@ -860,24 +880,39 @@ async function doChange() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
|
||||
try {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
const cryptoKey = await deriveKey(pass1, salt, true);
|
||||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
||||
const hexKey = await exportKeyHex(cryptoKey);
|
||||
// Fetch current salt to derive old key for verification
|
||||
const saltResp = await fetchAuth('/api/key-salt');
|
||||
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
|
||||
const saltData = await saltResp.json();
|
||||
if (!saltData.has_passphrase) throw new Error('No passphrase configured');
|
||||
|
||||
const resp = await fetchAuth('/api/key-setup', {
|
||||
// Derive old key and verify it
|
||||
const oldCryptoKey = await deriveKey(passOld, hexToBytes(saltData.salt), true);
|
||||
const validOld = await verifyKeyCheck(oldCryptoKey, saltData.key_check);
|
||||
if (!validOld) { showErr(errEl, t('errWrongOld')); return; }
|
||||
const oldHexKey = await exportKeyHex(oldCryptoKey);
|
||||
|
||||
// Derive new key
|
||||
const newSalt = crypto.getRandomValues(new Uint8Array(32));
|
||||
const newCryptoKey = await deriveKey(pass1, newSalt, true);
|
||||
const newKeyCheckHex = await encryptKeyCheck(newCryptoKey);
|
||||
const newHexKey = await exportKeyHex(newCryptoKey);
|
||||
|
||||
const resp = await fetchAuth('/api/key-change', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
salt: bytesToHex(salt),
|
||||
key_check: keyCheckHex,
|
||||
old_key: oldHexKey,
|
||||
new_key: newHexKey,
|
||||
salt: bytesToHex(newSalt),
|
||||
key_check: newKeyCheckHex,
|
||||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
||||
})
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
|
||||
currentEncKey = hexKey;
|
||||
sessionStorage.setItem('enc_key', hexKey);
|
||||
currentEncKey = newHexKey;
|
||||
sessionStorage.setItem('enc_key', newHexKey);
|
||||
renderRealConfig();
|
||||
closeChangeModal();
|
||||
} catch (e) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,10 +178,8 @@
|
||||
<a href="/llms.txt">llms.txt</a>
|
||||
<span data-i18n="sep"> · </span>
|
||||
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
|
||||
{% if !is_logged_in %}
|
||||
<span data-i18n="sep"> · </span>
|
||||
<a href="/login" data-i18n="footLogin">登录</a>
|
||||
{% endif %}
|
||||
<a href="/changelog" data-i18n="footChangelog">变更记录</a>
|
||||
</footer>
|
||||
<script>
|
||||
const T = {
|
||||
@@ -200,7 +198,7 @@
|
||||
versionLabel: '版本',
|
||||
sep: ' · ',
|
||||
footRepo: '源码仓库',
|
||||
footLogin: '登录',
|
||||
footChangelog: '变更记录',
|
||||
},
|
||||
'zh-TW': {
|
||||
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
|
||||
@@ -217,7 +215,7 @@
|
||||
versionLabel: '版本',
|
||||
sep: ' · ',
|
||||
footRepo: '原始碼倉庫',
|
||||
footLogin: '登入',
|
||||
footChangelog: '變更記錄',
|
||||
},
|
||||
'en': {
|
||||
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
|
||||
@@ -234,7 +232,7 @@
|
||||
versionLabel: 'Version',
|
||||
sep: ' · ',
|
||||
footRepo: 'Source repository',
|
||||
footLogin: 'Sign in',
|
||||
footChangelog: 'Changelog',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
83
crates/secrets-mcp/templates/i18n.js
Normal file
83
crates/secrets-mcp/templates/i18n.js
Normal file
@@ -0,0 +1,83 @@
|
||||
var I18N_SHARED = {
|
||||
'zh-CN': {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: '条目',
|
||||
navTrash: '回收站',
|
||||
navAudit: '审计',
|
||||
signOut: '退出',
|
||||
mobileLabelTime: '时间',
|
||||
mobileLabelAction: '动作',
|
||||
mobileLabelTarget: '目标',
|
||||
mobileLabelDetail: '详情'
|
||||
},
|
||||
'zh-TW': {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: '條目',
|
||||
navTrash: '回收站',
|
||||
navAudit: '審計',
|
||||
signOut: '登出',
|
||||
mobileLabelTime: '時間',
|
||||
mobileLabelAction: '動作',
|
||||
mobileLabelTarget: '目標',
|
||||
mobileLabelDetail: '詳情'
|
||||
},
|
||||
en: {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: 'Entries',
|
||||
navTrash: 'Trash',
|
||||
navAudit: 'Audit',
|
||||
signOut: 'Sign out',
|
||||
mobileLabelTime: 'Time',
|
||||
mobileLabelAction: 'Action',
|
||||
mobileLabelTarget: 'Target',
|
||||
mobileLabelDetail: 'Detail'
|
||||
}
|
||||
};
|
||||
|
||||
var currentLang = localStorage.getItem('lang') || 'zh-CN';
|
||||
var I18N_PAGE = {};
|
||||
|
||||
function t(key) {
|
||||
var dict = I18N_PAGE[currentLang] || I18N_PAGE['en'] || {};
|
||||
var val = dict[key] || (I18N_SHARED[currentLang] && I18N_SHARED[currentLang][key]) || (I18N_SHARED.en && I18N_SHARED.en[key]) || key;
|
||||
return val;
|
||||
}
|
||||
|
||||
function tf(key, vars) {
|
||||
var tpl = t(key);
|
||||
return Object.keys(vars || {}).reduce(function (acc, k) {
|
||||
return acc.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]));
|
||||
}, tpl);
|
||||
}
|
||||
|
||||
function applyLang() {
|
||||
document.documentElement.lang = currentLang;
|
||||
var title = t('pageTitle');
|
||||
if (title) document.title = title;
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-i18n-ph');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
document.querySelectorAll('.lang-btn').forEach(function (btn) {
|
||||
var map = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' };
|
||||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
||||
});
|
||||
if (typeof applyPageLang === 'function') applyPageLang();
|
||||
}
|
||||
|
||||
window.setLang = function (lang) {
|
||||
currentLang = lang;
|
||||
localStorage.setItem('lang', lang);
|
||||
applyLang();
|
||||
};
|
||||
275
crates/secrets-mcp/templates/trash.html
Normal file
275
crates/secrets-mcp/templates/trash.html
Normal file
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
|
||||
<title data-i18n="pageTitle">Secrets — 回收站</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
|
||||
:root {
|
||||
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.main { padding: 16px 16px 24px; flex: 1; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; color: #fff; }
|
||||
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); vertical-align: top; }
|
||||
th { color: #8b949e; font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; color: #c9d1d9; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn { border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #c9d1d9; border-radius: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; font-family: inherit; }
|
||||
.btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-danger { color: #f85149; }
|
||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||
.pagination {
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 18px;
|
||||
justify-content: center; padding: 12px 0;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.page-btn.disabled {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #6e7681; font-size: 13px;
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.page-info {
|
||||
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
|
||||
.main { padding: 20px 12px 28px; }
|
||||
.card { padding: 16px; }
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
thead { display: none; }
|
||||
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
|
||||
td { border-top: none; padding: 6px 0; }
|
||||
td::before {
|
||||
display: block; color: #8b949e; font-size: 11px;
|
||||
margin-bottom: 4px; text-transform: uppercase;
|
||||
content: attr(data-label);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link active" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="content-shell">
|
||||
<div class="topbar">
|
||||
<span class="topbar-spacer"></span>
|
||||
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
|
||||
<div class="lang-bar">
|
||||
<button class="lang-btn" onclick="setLang('zh-CN')">简</button>
|
||||
<button class="lang-btn" onclick="setLang('zh-TW')">繁</button>
|
||||
<button class="lang-btn" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
<form action="/auth/logout" method="post" style="display:inline">
|
||||
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div class="card-title" data-i18n="trashTitle">回收站</div>
|
||||
<div class="card-subtitle" data-i18n="trashSubtitle">已删除条目会保留 3 个月,可在此恢复或永久删除。</div>
|
||||
|
||||
{% if entries.is_empty() %}
|
||||
<div class="empty" data-i18n="emptyTrash">回收站为空。</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="colName">名称</th>
|
||||
<th data-i18n="colType">类型</th>
|
||||
<th data-i18n="colFolder">文件夹</th>
|
||||
<th data-i18n="colDeletedAt">删除时间</th>
|
||||
<th data-i18n="colActions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr data-trash-entry-id="{{ entry.id }}">
|
||||
<td class="mono" data-label="名称">{{ entry.name }}</td>
|
||||
<td class="mono" data-label="类型">{{ entry.entry_type }}</td>
|
||||
<td class="mono" data-label="文件夹">{{ entry.folder }}</td>
|
||||
<td class="mono" data-label="删除时间" title="{{ entry.deleted_at_iso }}">{{ entry.deleted_at_label }}</td>
|
||||
<td data-label="操作">
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn btn-restore" data-i18n="btnRestore">恢复</button>
|
||||
<button type="button" class="btn btn-danger btn-purge" data-i18n="btnPurge">永久删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a class="page-btn" href="/trash?page={{ current_page - 1 }}" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a class="page-btn" href="/trash?page={{ current_page + 1 }}" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
I18N_PAGE = {
|
||||
'zh-CN': {
|
||||
pageTitle: 'Secrets — 回收站',
|
||||
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
|
||||
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。',
|
||||
emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹',
|
||||
colDeletedAt: '删除时间', colActions: '操作', btnRestore: '恢复', btnPurge: '永久删除',
|
||||
prevPage: '上一页', nextPage: '下一页',
|
||||
mobileLabelName: '名称', mobileLabelType: '类型', mobileLabelFolder: '文件夹',
|
||||
mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作'
|
||||
},
|
||||
'zh-TW': {
|
||||
pageTitle: 'Secrets — 回收站',
|
||||
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。',
|
||||
emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾',
|
||||
colDeletedAt: '刪除時間', colActions: '操作', btnRestore: '恢復', btnPurge: '永久刪除',
|
||||
prevPage: '上一頁', nextPage: '下一頁',
|
||||
mobileLabelName: '名稱', mobileLabelType: '類型', mobileLabelFolder: '文件夾',
|
||||
mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作'
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Secrets — Trash',
|
||||
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
||||
signOut: 'Sign out', trashTitle: 'Trash', trashSubtitle: 'Deleted entries are kept for 3 months. Restore or permanently delete them here.',
|
||||
emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder',
|
||||
colDeletedAt: 'Deleted at', colActions: 'Actions', btnRestore: 'Restore', btnPurge: 'Purge',
|
||||
prevPage: 'Previous', nextPage: 'Next',
|
||||
mobileLabelName: 'Name', mobileLabelType: 'Type', mobileLabelFolder: 'Folder',
|
||||
mobileLabelDeletedAt: 'Deleted at', mobileLabelActions: 'Actions'
|
||||
}
|
||||
};
|
||||
|
||||
window.applyPageLang = function () {
|
||||
document.querySelectorAll('tbody tr').forEach(function (tr) {
|
||||
['Name', 'Type', 'Folder', 'DeletedAt', 'Actions'].forEach(function (col) {
|
||||
var td = tr.querySelector('[data-label]');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
applyLang();
|
||||
})();
|
||||
|
||||
document.querySelectorAll('tr[data-trash-entry-id]').forEach(function (row) {
|
||||
var entryId = row.getAttribute('data-trash-entry-id');
|
||||
var restoreButton = row.querySelector('.btn-restore');
|
||||
var purgeButton = row.querySelector('.btn-purge');
|
||||
|
||||
restoreButton.addEventListener('click', function () {
|
||||
fetch('/api/trash/' + encodeURIComponent(entryId) + '/restore', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
|
||||
return body;
|
||||
});
|
||||
}).then(function () {
|
||||
row.remove();
|
||||
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
|
||||
}).catch(function (error) {
|
||||
window.alert(error.message || String(error));
|
||||
});
|
||||
});
|
||||
|
||||
purgeButton.addEventListener('click', function () {
|
||||
if (!window.confirm(t('confirmPurge') || '确定永久删除该条目?此操作不可撤销。')) return;
|
||||
fetch('/api/trash/' + encodeURIComponent(entryId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
|
||||
return body;
|
||||
});
|
||||
}).then(function () {
|
||||
row.remove();
|
||||
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
|
||||
}).catch(function (error) {
|
||||
window.alert(error.message || String(error));
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,9 +20,14 @@ BASE_URL=https://secrets.example.com
|
||||
|
||||
# ─── Google OAuth ─────────────────────────────────────────────────────
|
||||
# Google Cloud Console → APIs & Services → Credentials
|
||||
# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback
|
||||
# 授权回调 URI 须与 BASE_URL 完全一致:${BASE_URL}/auth/google/callback(含 http/https、主机名、端口)
|
||||
# 运行 secrets-mcp 的机器须能访问 Google(oauth2.googleapis.com)。若本机用 Clash/Surge「系统代理」上网:
|
||||
# 构建时已启用 reqwest 的 system-proxy,进程会跟随系统代理;仍失败时可设 HTTPS_PROXY(见下方)。
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# 若仍无法换 token(仅提供端口代理、无系统代理):可取消注释并改为本机代理地址
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
|
||||
# WECHAT_APP_CLIENT_ID=
|
||||
@@ -31,7 +36,31 @@ GOOGLE_CLIENT_SECRET=
|
||||
# ─── 日志(可选)──────────────────────────────────────────────────────
|
||||
# RUST_LOG=secrets_mcp=debug
|
||||
|
||||
# ─── 注意 ─────────────────────────────────────────────────────────────
|
||||
# SERVER_MASTER_KEY 已不再需要。
|
||||
# 新架构(E2EE)中,加密密钥由用户密码短语在客户端本地派生,服务端不持有原始密钥。
|
||||
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。
|
||||
# ─── 数据库连接池(可选)──────────────────────────────────────────────
|
||||
# 最大连接数,默认 10
|
||||
# SECRETS_DATABASE_POOL_SIZE=10
|
||||
# 获取连接超时秒数,默认 5
|
||||
# SECRETS_DATABASE_ACQUIRE_TIMEOUT=5
|
||||
|
||||
# ─── 限流(可选)──────────────────────────────────────────────────────
|
||||
# 全局限流速率(req/s),默认 100
|
||||
# RATE_LIMIT_GLOBAL_PER_SECOND=100
|
||||
# 全局限流突发量,默认 200
|
||||
# RATE_LIMIT_GLOBAL_BURST=200
|
||||
# 单 IP 限流速率(req/s),默认 20
|
||||
# RATE_LIMIT_IP_PER_SECOND=20
|
||||
# 单 IP 限流突发量,默认 40
|
||||
# RATE_LIMIT_IP_BURST=40
|
||||
|
||||
# ─── 代理信任(可选)─────────────────────────────────────────────────
|
||||
# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP
|
||||
# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# ─── 本机 MCP gateway(secrets-mcp-local,可选)────────────────────────
|
||||
# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于本地 MCP onboarding、解锁缓存与 target_exec。
|
||||
# 直接配置远端 Web 基址。
|
||||
# SECRETS_REMOTE_BASE_URL=https://secrets.example.com
|
||||
# SECRETS_MCP_LOCAL_BIND=127.0.0.1:9316
|
||||
# SECRETS_LOCAL_UNLOCK_TTL_SECS=3600
|
||||
# SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS=3600
|
||||
|
||||
201
plans/code-review-fixes-2026-04-11.md
Normal file
201
plans/code-review-fixes-2026-04-11.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Code Review 修复计划
|
||||
|
||||
**日期**: 2026-04-11
|
||||
**来源**: 三份 code review 报告提炼合并
|
||||
**范围**: 7 项修复 + 1 项风险提示(不纳入本次修复)
|
||||
|
||||
---
|
||||
|
||||
## 1. `secrets-core` 导入/导出链路
|
||||
|
||||
**目标**: 修复"导入丢失 secret type"。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-core/src/models.rs`
|
||||
- `crates/secrets-core/src/service/export.rs`
|
||||
- `crates/secrets-core/src/service/import.rs`
|
||||
|
||||
**实施项**:
|
||||
1. 在 `ExportEntry` 增加可选字段 `secret_types: Option<BTreeMap<String, String>>`,键为 secret 名,值为 type。
|
||||
2. 修改导出逻辑,除了导出 `secrets` 的值,也把每个 secret 的 `type` 一并导出。
|
||||
3. 修改导入逻辑,把 `entry.secret_types` 传给 `AddParams.secret_types`,不再用 `&Default::default()`。
|
||||
4. 明确兼容旧导出文件:`secret_types` 缺失时继续默认 `"text"`。
|
||||
|
||||
**验证**:
|
||||
1. 新增 round-trip 测试:带 `password` / `key` / `text` 三种类型的导出再导入,类型保持不变。
|
||||
2. 新增向后兼容测试:旧格式导入时仍成功,默认回落到 `"text"`。
|
||||
|
||||
---
|
||||
|
||||
## 2. `secrets-core` env map
|
||||
|
||||
**目标**: 修复环境变量名碰撞。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-core/src/service/env_map.rs`
|
||||
|
||||
**实施项**:
|
||||
1. 统一分隔符策略:把字段名里的 `.` 替换成 `__`(双下划线),保留原始 `_` 为单下划线,避免 `db.password` 和 `db_password` 碰撞。
|
||||
2. 如仍发生碰撞,显式返回错误,而不是 `HashMap::insert` 静默覆盖。
|
||||
|
||||
**验证**:
|
||||
1. 添加测试覆盖:
|
||||
- `db.password` vs `db_password`
|
||||
- 带 `prefix` 的情况
|
||||
- 多 entry 合并时碰撞
|
||||
2. 确认输出变量名文档与实现一致。
|
||||
|
||||
---
|
||||
|
||||
## 3. `secrets-core` rollback
|
||||
|
||||
**目标**: 修复回滚路径中的 TOCTOU 和"使用旧 live 值"问题。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-core/src/service/rollback.rs`
|
||||
|
||||
**实施项**:
|
||||
1. 把首次读取 live entry 的逻辑移入事务内,并与 `FOR UPDATE` 合并,避免在加锁前基于过期数据做决策。
|
||||
2. 在拿到加锁后的 live row 后,再决定错误消息、审计字段和更新语句输入。
|
||||
3. 明确回滚语义:
|
||||
- 若产品希望"完全回到快照",则 `name/notes` 也从快照恢复。
|
||||
- 若希望保留当前标识,则代码和文档都要显式说明此设计决策。
|
||||
4. 避免混用事务前的 `live_entry` 与事务内的 `lr`。
|
||||
|
||||
**验证**:
|
||||
1. 新增测试覆盖:
|
||||
- 回滚时恢复字段是否符合预期
|
||||
- 并发更新 + 回滚场景不再依赖过期值
|
||||
|
||||
---
|
||||
|
||||
## 4. `secrets-core` API key
|
||||
|
||||
**目标**: 修复 `regenerate_api_key` 返回未持久化 key。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-core/src/service/api_key.rs`
|
||||
|
||||
**实施项**:
|
||||
1. 检查 `UPDATE` 的 `rows_affected()`。
|
||||
2. 若为 `0`,返回 `NotFoundUser` 或等价业务错误。
|
||||
3. 可选:改成 `UPDATE ... RETURNING api_key`,减少"先生成后判断"的分支。
|
||||
|
||||
**验证**:
|
||||
1. 新增测试:
|
||||
- 正常用户可返回新 key
|
||||
- 不存在用户时返回错误,而不是返回伪成功 key
|
||||
|
||||
---
|
||||
|
||||
## 5. `secrets-mcp` tools
|
||||
|
||||
**目标**: 修复 MCP 层三个问题:
|
||||
- `secrets_add` 提交后再回查 entry 的竞态
|
||||
- `secrets_find` 计数失败被吞成 0
|
||||
- `secrets_rollback` 冗余要求 encryption key
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-mcp/src/tools.rs`
|
||||
- 可能连带 `crates/secrets-core/src/service/add.rs` 的返回结构
|
||||
|
||||
**实施项**:
|
||||
1. 让 `svc_add` 直接返回 `entry_id`,MCP 不再在提交后用 `name+folder+user_id` 二次 `resolve_entry`。
|
||||
2. `secrets_find` 中移除 `.unwrap_or(0)`:
|
||||
- 要么把计数错误向上传播
|
||||
- 要么返回 `total_count: null` / `count_unavailable: true`
|
||||
3. `secrets_rollback` 改为只要求用户身份,不要求 encryption key。
|
||||
4. 同步修正工具描述文案,避免 schema 与实际行为不一致。
|
||||
|
||||
**验证**:
|
||||
1. `secrets_add`:父子关系新增时直接使用返回的 `entry_id`。
|
||||
2. `secrets_find`:模拟 count 失败时,结果不再伪装成 `0`。
|
||||
3. `secrets_rollback`:无 key 时可执行,工具描述与行为一致。
|
||||
|
||||
---
|
||||
|
||||
## 6. `secrets-mcp` Web 会话校验
|
||||
|
||||
**目标**: 让 JSON API 与页面路由的 `key_version` 校验对齐。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-mcp/src/web/mod.rs`
|
||||
- `crates/secrets-mcp/src/web/entries.rs`
|
||||
- `crates/secrets-mcp/src/web/account.rs`
|
||||
- 其他仅使用 `current_user_id()` 的 JSON handler
|
||||
|
||||
**实施项**:
|
||||
1. 抽一个适用于 JSON API 的用户校验辅助函数。
|
||||
2. 该函数应同时完成:
|
||||
- session 取 `user_id`
|
||||
- 加载用户
|
||||
- 比对 `SESSION_KEY_VERSION` 与 DB `key_version`
|
||||
3. 页面路由继续保留 `require_valid_user`,JSON 路由统一改用等价校验。
|
||||
4. 统一失败语义:
|
||||
- HTML 路由:重定向 `/login`
|
||||
- JSON 路由:返回 `401`
|
||||
|
||||
**验证**:
|
||||
1. 新增测试覆盖:
|
||||
- `key_version` 一致时 JSON API 正常
|
||||
- `key_version` 不一致时 JSON API 返回 `401`
|
||||
- 用户删除/会话损坏时返回正确错误
|
||||
|
||||
---
|
||||
|
||||
## 7. Web API 输入校验
|
||||
|
||||
**目标**: 补齐 Web JSON API 的长度校验,避免 DB 约束错误落成 500。
|
||||
|
||||
**涉及文件**:
|
||||
- `crates/secrets-mcp/src/web/entries.rs`
|
||||
- 如有需要,抽公共 helper 到 `web/mod.rs` 或单独模块
|
||||
|
||||
**实施项**:
|
||||
1. 在 `api_entry_patch` 对齐 MCP 的长度规则:
|
||||
- `folder <= 128`
|
||||
- `type <= 64`
|
||||
- `name <= 256`
|
||||
- `notes <= 10000`
|
||||
2. 视情况复用 `validation` 常量,避免 Web/MCP 两套上限漂移。
|
||||
3. 保持错误返回为 `400`,而不是依赖数据库报错。
|
||||
|
||||
**验证**:
|
||||
1. 为超长 `folder/type/name/notes` 分别补测试。
|
||||
2. 确认错误文案和现有本地化风格一致。
|
||||
|
||||
---
|
||||
|
||||
## 8. 暂不纳入本次修复(风险提示)
|
||||
|
||||
- 调试日志记录加密密钥片段(`extract_enc_key_or_arg` 在 debug 级别记录 key 前后缀)
|
||||
- `encryption_key` 作为工具参数带来的日志/对话暴露面
|
||||
|
||||
**处理方式**: 记录为"接口安全风险提示",待后续单独决定是否收紧 debug 日志、调整工具描述或限制参数传 key 的路径。
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. `secrets-core` 导入/导出链路
|
||||
2. `secrets-core` env map
|
||||
3. `secrets-core` rollback
|
||||
4. `secrets-core` API key
|
||||
5. `secrets-mcp` tools
|
||||
6. `secrets-mcp` Web 会话校验
|
||||
7. `secrets-mcp` Web 输入校验
|
||||
|
||||
**原因**: 先修 core 语义和返回结构,再修上层接入。`svc_add` 返回结构、rollback 语义、export/import 格式都属于底层契约,适合先稳定。
|
||||
|
||||
---
|
||||
|
||||
## 验证与收尾
|
||||
|
||||
1. 先跑相关单元/集成测试。
|
||||
2. 再跑全量:
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo test --locked
|
||||
```
|
||||
3. 若涉及 `crates/**` 的实际改动,按 `AGENTS.md` 约定检查版本/tag,并执行 `./scripts/release-check.sh`。
|
||||
141
plans/merge-code-review-fixes-2026-04-11.md
Normal file
141
plans/merge-code-review-fixes-2026-04-11.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Code Review 修复方案合并计划
|
||||
|
||||
**日期**: 2026-04-11
|
||||
**来源**: 两个 AI 实现对比评估
|
||||
**比较对象**:
|
||||
|
||||
- `d7720662` (`/Users/voson/work/refining/secrets-cr-fixes-ws`)
|
||||
- `9f8a68cd` (`/Users/voson/work/refining/secrets/plan-impl`)
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
以 `**d7720662`** 为主线采纳。
|
||||
|
||||
**原因**:
|
||||
|
||||
1. `rollback` 的 live row 加锁与 snapshot 读取都在事务内完成,更符合原计划里对 TOCTOU 的修复要求。
|
||||
2. Web JSON API 的 session 校验保留了按 `UiLang` 返回错误信息的行为,没有把错误消息退化成固定英文。
|
||||
3. `svc_add` 返回 `entry_id`,MCP 层直接使用返回值建立 parent relation,和计划第 5 项更一致。
|
||||
|
||||
---
|
||||
|
||||
## 采纳策略
|
||||
|
||||
### 1. 主线保留 `d7720662`
|
||||
|
||||
保留以下实现,不从另一份实现回退:
|
||||
|
||||
- `crates/secrets-core/src/service/rollback.rs`
|
||||
- `crates/secrets-mcp/src/web/mod.rs`
|
||||
- `crates/secrets-core/src/service/add.rs`
|
||||
- `crates/secrets-mcp/src/tools.rs`
|
||||
|
||||
### 2. 从 `9f8a68cd` 手动吸收的小改动
|
||||
|
||||
仅吸收下面两处,手动改写,不直接整文件 cherry-pick:
|
||||
|
||||
1. `crates/secrets-mcp/src/web/entries.rs`
|
||||
- 把长度校验报错文案改成基于 `crate::validation::`* 常量拼接,避免上限数字硬编码在文案里。
|
||||
2. `crates/secrets-core/src/service/env_map.rs`
|
||||
- 补 `env_prefix_with_and_without_prefix` 单测。
|
||||
|
||||
---
|
||||
|
||||
## 明确不采纳的实现
|
||||
|
||||
### 不采纳 `9f8a68cd` 的 `rollback.rs`
|
||||
|
||||
原因:
|
||||
|
||||
- 它仍然先在事务外读取 `entries_history`,再开启事务并锁 live row。
|
||||
- 对“回滚到最近快照”的路径仍存在先读后锁的时间窗口。
|
||||
|
||||
### 不采纳 `9f8a68cd` 的 `web/mod.rs`
|
||||
|
||||
原因:
|
||||
|
||||
- `load_session_user_strict()` / `require_valid_user_json()` 返回固定英文 JSON 错误。
|
||||
- 会丢失现有多语言错误语义。
|
||||
|
||||
### 不采纳 `9f8a68cd` 的 `AddResult.id`
|
||||
|
||||
原因:
|
||||
|
||||
- 本轮计划里明确要求 `svc_add` 返回 `entry_id`。
|
||||
- `d7720662` 的字段命名与 MCP 使用方式更贴近计划要求。
|
||||
|
||||
---
|
||||
|
||||
## 与原计划的覆盖情况
|
||||
|
||||
两份实现都完成了大部分代码修改,但验证项整体没有补齐,当前更像“代码已改,测试仍不足”。
|
||||
|
||||
### 已基本完成
|
||||
|
||||
1. 导入/导出链路补 `secret_types`
|
||||
2. env map `.` -> `__`,并在冲突时返回错误
|
||||
3. API key `rows_affected()` 检查
|
||||
4. MCP `secrets_add` 避免提交后二次回查竞态
|
||||
5. MCP `secrets_find` 不再把 count 错误吞成 `0`
|
||||
6. MCP `secrets_rollback` 不再要求 encryption key
|
||||
7. Web JSON API 引入 `key_version` 严格校验
|
||||
8. Web PATCH 补长度校验
|
||||
|
||||
### 尚需补齐的验证
|
||||
|
||||
1. export/import round-trip 测试
|
||||
- `password` / `key` / `text` 三种类型导出再导入后保持不变
|
||||
2. legacy import 测试
|
||||
- 老格式缺失 `secret_types` 时默认回落到 `text`
|
||||
3. env map 测试
|
||||
- `db.password` vs `db_password`
|
||||
- 带 `prefix`
|
||||
- 多 entry 合并冲突
|
||||
4. rollback 测试
|
||||
- 恢复字段是否符合预期
|
||||
- 并发更新 + 回滚不依赖过期值
|
||||
5. `regenerate_api_key` 测试
|
||||
- 正常用户返回新 key
|
||||
- 不存在用户返回错误
|
||||
6. MCP tool 测试
|
||||
- `secrets_find` count 失败路径
|
||||
- `secrets_rollback` 无 encryption key 也可执行
|
||||
7. Web session / validation 测试
|
||||
- `key_version` mismatch -> `401`
|
||||
- 用户不存在 / session 损坏 -> 正确错误
|
||||
- `folder/type/name/notes` 超长 -> `400`
|
||||
|
||||
---
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 以 `d7720662` 对应实现为合并基线。
|
||||
2. 手动吸收 `9f8a68cd` 中 `web/entries.rs` 的常量化长度报错文案。
|
||||
3. 手动吸收 `9f8a68cd` 中 `env_map.rs` 的 `env_prefix_with_and_without_prefix` 测试。
|
||||
4. 不引入 `9f8a68cd` 的 `rollback.rs`、`web/mod.rs`、`AddResult.id` 方案。
|
||||
5. 针对原计划缺失的验证项补测试。
|
||||
6. 跑质量门禁:
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
1. 跑发布前检查:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
1. 确认版本和 tag:
|
||||
- `crates/secrets-mcp/Cargo.toml` 已 bump(合并执行时为 `0.5.21`,因 `crates/**` 有变更)
|
||||
- `jj tag list`
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
如果后续要做最终合并,建议以 `d7720662` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。
|
||||
392
plans/metadata-search-and-entry-relations.md
Normal file
392
plans/metadata-search-and-entry-relations.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Metadata Value Search & Entry Relations (DAG)
|
||||
|
||||
## Overview
|
||||
|
||||
Two new features for secrets-mcp:
|
||||
|
||||
1. **Metadata Value Search** — fuzzy search across all JSON scalar values in `metadata`, excluding keys
|
||||
2. **Entry Relations** — directional parent-child associations between entries (DAG, multiple parents allowed, cycle detection)
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Metadata Value Search
|
||||
|
||||
### Problem
|
||||
|
||||
The existing `query` parameter in `secrets_find`/`secrets_search` searches `metadata::text ILIKE`, which matches keys, JSON punctuation, and structural characters. Users want to search **only metadata values** (e.g. find entries where any metadata value contains "1.2.3.4", regardless of key name).
|
||||
|
||||
### Solution
|
||||
|
||||
Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_path_query` to iterate over only scalar values (strings, numbers, booleans), then applies ILIKE matching.
|
||||
|
||||
### Changes
|
||||
|
||||
#### secrets-core
|
||||
|
||||
`**crates/secrets-core/src/service/search.rs`**
|
||||
|
||||
- Add `metadata_query: Option<&'a str>` field to `SearchParams`
|
||||
- In `entry_where_clause_and_next_idx`, when `metadata_query` is set, add:
|
||||
|
||||
```sql
|
||||
EXISTS (
|
||||
SELECT 1 FROM jsonb_path_query(
|
||||
entries.metadata,
|
||||
'strict $.** ? (@.type() != "object" && @.type() != "array")'
|
||||
) AS val
|
||||
WHERE (val#>>'{}') ILIKE $N ESCAPE '\'
|
||||
)
|
||||
```
|
||||
|
||||
- Bind `ilike_pattern(metadata_query)` at the correct `$N` position in both `fetch_entries_paged` and `count_entries`
|
||||
|
||||
#### secrets-mcp (MCP tools)
|
||||
|
||||
`**crates/secrets-mcp/src/tools.rs**`
|
||||
|
||||
- Add `metadata_query` field to `FindInput`:
|
||||
|
||||
```rust
|
||||
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
|
||||
metadata_query: Option<String>,
|
||||
```
|
||||
|
||||
- Add same field to `SearchInput`
|
||||
- Pass `metadata_query` through to `SearchParams` in both `secrets_find` and `secrets_search` handlers
|
||||
|
||||
#### secrets-mcp (Web)
|
||||
|
||||
`**crates/secrets-mcp/src/web/entries.rs**`
|
||||
|
||||
- Add `metadata_query: Option<String>` to `EntriesQuery`
|
||||
- Thread it into all `SearchParams` usages (count, list, folder counts)
|
||||
- Pass it into template context
|
||||
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
|
||||
- Include `metadata_query` in pagination `href` links
|
||||
|
||||
`**crates/secrets-mcp/templates/entries.html**`
|
||||
|
||||
- Add a "metadata 值" text input to the filter bar (after name, before type)
|
||||
- Preserve value in the input on re-render
|
||||
|
||||
### i18n Keys
|
||||
|
||||
|
||||
| Key | zh | zh-Hant | en |
|
||||
| ----------------------- | ------ | ------- | ---------------------- |
|
||||
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
|
||||
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
|
||||
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- The `jsonb_path_query` with `$.**` scans all nested values recursively; this is a sequential scan on the metadata column per row
|
||||
- The existing GIN index on `metadata jsonb_path_ops` supports `@>` containment queries but NOT this pattern
|
||||
- For production datasets > 10k entries, consider a generated column or materialized search column in a future iteration
|
||||
- First version prioritizes semantic correctness over index optimization
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Entry Relations (DAG)
|
||||
|
||||
### Data Model
|
||||
|
||||
New table `entry_relations`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (parent_entry_id, child_entry_id),
|
||||
CHECK (parent_entry_id <> child_entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||
CREATE INDEX idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||
|
||||
-- Enforce multi-tenant isolation: parent and child must belong to same user
|
||||
ALTER TABLE entry_relations ADD CONSTRAINT fk_parent_user
|
||||
FOREIGN KEY (parent_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
|
||||
ALTER TABLE entry_relations ADD CONSTRAINT fk_child_user
|
||||
FOREIGN KEY (child_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
|
||||
```
|
||||
|
||||
Shared secrets already use `entry_secrets` as an N:N relation, so this is consistent with the existing pattern.
|
||||
|
||||
### Cycle Detection
|
||||
|
||||
On every `INSERT INTO entry_relations(parent, child)`, check that no path exists from `child` back to `parent`:
|
||||
|
||||
```sql
|
||||
-- Returns true if adding (parent, child) would create a cycle
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM entry_relations
|
||||
WHERE child_entry_id = $1 -- $1 = proposed parent
|
||||
START WITH parent_entry_id = $2 -- $2 = proposed child
|
||||
CONNECT BY PRIOR child_entry_id = parent_entry_id
|
||||
);
|
||||
```
|
||||
|
||||
Wait — PostgreSQL doesn't support `START WITH ... CONNECT BY`. Use recursive CTE instead:
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT parent_entry_id AS ancestor
|
||||
FROM entry_relations
|
||||
WHERE child_entry_id = $1 -- proposed child
|
||||
UNION ALL
|
||||
SELECT er.parent_entry_id
|
||||
FROM entry_relations er
|
||||
JOIN chain c ON c.ancestor = er.child_entry_id
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM chain WHERE ancestor = $2);
|
||||
-- $1 = proposed child, $2 = proposed parent
|
||||
```
|
||||
|
||||
If `EXISTS` returns true, reject with `AppError::Validation { message: "cycle detected" }`.
|
||||
|
||||
### secrets-core Changes
|
||||
|
||||
**New file: `crates/secrets-core/src/service/relations.rs`**
|
||||
|
||||
```rust
|
||||
pub struct RelationSummary {
|
||||
pub parent_id: Uuid,
|
||||
pub parent_name: String,
|
||||
pub parent_folder: String,
|
||||
pub parent_type: String,
|
||||
}
|
||||
|
||||
pub struct AddRelationParams<'a> {
|
||||
pub parent_entry_id: Uuid,
|
||||
pub child_entry_id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct RemoveRelationParams<'a> {
|
||||
pub parent_entry_id: Uuid,
|
||||
pub child_entry_id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Add a parent→child relation. Validates:
|
||||
/// - Both entries exist and belong to the same user
|
||||
/// - No self-reference (enforced by CHECK constraint)
|
||||
/// - No cycle (recursive CTE check)
|
||||
pub async fn add_relation(pool: &PgPool, params: AddRelationParams<'_>) -> Result<()>
|
||||
|
||||
/// Remove a parent→child relation.
|
||||
pub async fn remove_relation(pool: &PgPool, params: RemoveRelationParams<'_>) -> Result<()>
|
||||
|
||||
/// Get all parents of an entry (with summary info).
|
||||
pub async fn get_parents(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
|
||||
|
||||
/// Get all children of an entry (with summary info).
|
||||
pub async fn get_children(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
|
||||
|
||||
/// Get parents + children for a batch of entry IDs (for list pages).
|
||||
pub async fn get_relations_for_entries(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
|
||||
```
|
||||
|
||||
`**crates/secrets-core/src/service/mod.rs**` — add `pub mod relations;`
|
||||
|
||||
`**crates/secrets-core/src/db.rs**` — add entry_relations table creation in `migrate()`
|
||||
|
||||
`**crates/secrets-core/src/error.rs**` — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
|
||||
|
||||
### MCP Tool Changes
|
||||
|
||||
`**crates/secrets-mcp/src/tools.rs**`
|
||||
|
||||
1. `**secrets_add**` (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
|
||||
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
|
||||
- After creating the entry, call `relations::add_relation` for each parent
|
||||
2. `**secrets_update**` (`UpdateInput`): add two fields:
|
||||
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
|
||||
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
|
||||
3. `**secrets_find**` and `secrets_search` output: add `parents` and `children` arrays to each entry result:
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"name": "...",
|
||||
"parents": [{"id": "...", "name": "...", "folder": "...", "type": "..."}],
|
||||
"children": [{"id": "...", "name": "...", "folder": "...", "type": "..."}]
|
||||
}
|
||||
```
|
||||
- Fetch relations for all returned entry IDs in a single batch query
|
||||
|
||||
### Web Changes
|
||||
|
||||
`**crates/secrets-mcp/src/web/entries.rs**`
|
||||
|
||||
1. **New API endpoints:**
|
||||
- `POST /api/entries/{id}/relations` — add parent relation
|
||||
- Body: `{ "parent_id": "uuid" }`
|
||||
- Validates same-user ownership and cycle detection
|
||||
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
|
||||
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
|
||||
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
|
||||
- Used by the edit dialog's parent selection autocomplete
|
||||
2. **Entry list template data** — include parent/child counts per entry row
|
||||
3. `**api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
|
||||
- When present, replace all parent relations for this entry with the given list
|
||||
- This is simpler than incremental add/remove in the Web UI context
|
||||
|
||||
`**crates/secrets-mcp/templates/entries.html**`
|
||||
|
||||
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
|
||||
2. **Edit dialog**: add "上级条目" (parent entries) section
|
||||
- Show current parents as removable chips
|
||||
- Add a search-as-you-type input that queries `/api/entries/options`
|
||||
- Click a search result to add it as parent
|
||||
- On save, send `parent_ids` in the PATCH body
|
||||
3. **View dialog / detail**: show "下级条目" (children) list with clickable links that navigate to the child entry
|
||||
4. **i18n**: add keys for all new UI elements
|
||||
|
||||
### i18n Keys (Entry Relations)
|
||||
|
||||
|
||||
| Key | zh | zh-Hant | en |
|
||||
| -------------------------- | ------------ | ------------ | -------------------------------- |
|
||||
| `colRelations` | 关联 | 關聯 | Relations |
|
||||
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
|
||||
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
|
||||
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
|
||||
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
|
||||
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
|
||||
| `noParents` | 无上级 | 無上級 | No parents |
|
||||
| `noChildren` | 无下级 | 無下級 | No children |
|
||||
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
|
||||
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Log relation changes in the existing `audit::log_tx` system:
|
||||
|
||||
- Action: `"add_relation"` / `"remove_relation"`
|
||||
- Detail JSON: `{ "parent_id": "...", "parent_name": "...", "child_id": "...", "child_name": "..." }`
|
||||
|
||||
### Export / Import
|
||||
|
||||
`**ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
|
||||
|
||||
```rust
|
||||
pub struct ParentRef {
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
}
|
||||
```
|
||||
|
||||
- On export, resolve each entry's parent IDs to `(folder, name)` pairs
|
||||
- On import, two-phase:
|
||||
1. Create all entries (skip parents)
|
||||
2. For each entry with `parents`, resolve `(folder, name)` → `entry_id` and call `add_relation`
|
||||
3. If a parent reference cannot be resolved, log a warning and skip it (don't fail the entire import)
|
||||
|
||||
### History / Rollback
|
||||
|
||||
- Relation changes are **not** versioned in `entries_history`. They are tracked only via `audit_log`.
|
||||
- Rationale: relations are a cross-entry concern; rolling them back alongside entry fields would require complex multi-entry coordination. The audit log provides sufficient traceability.
|
||||
- If the user explicitly requests rollback of relations in the future, it can be implemented as a separate feature.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Metadata Value Search
|
||||
|
||||
1. `secrets-core/src/service/search.rs` — add `metadata_query` to `SearchParams`, implement SQL condition
|
||||
2. `secrets-mcp/src/tools.rs` — add `metadata_query` to `FindInput` and `SearchInput`, wire through
|
||||
3. `secrets-mcp/src/web/entries.rs` — add `metadata_query` to `EntriesQuery`, `SearchParams`, pagination, folder counts
|
||||
4. `secrets-mcp/templates/entries.html` — add input field, i18n
|
||||
5. Test: existing `query` still works; `metadata_query` only matches values
|
||||
|
||||
### Phase 2: Entry Relations (Core)
|
||||
|
||||
1. `secrets-core/src/db.rs` — add `entry_relations` table to `migrate()`
|
||||
2. `secrets-core/src/service/relations.rs` — implement `add_relation`, `remove_relation`, `get_parents`, `get_children`, `get_relations_for_entries`, cycle detection
|
||||
3. `secrets-core/src/service/mod.rs` — add `pub mod relations`
|
||||
4. Test: add/remove/query relations, cycle detection, same-user validation
|
||||
|
||||
### Phase 3: Entry Relations (MCP)
|
||||
|
||||
1. `secrets-mcp/src/tools.rs` — extend `AddInput`, `UpdateInput` with parent IDs
|
||||
2. `secrets-mcp/src/tools.rs` — extend `secrets_find`/`secrets_search` output with `parents`/`children`
|
||||
3. Test: MCP tools work end-to-end
|
||||
|
||||
### Phase 4: Entry Relations (Web)
|
||||
|
||||
1. `secrets-mcp/src/web/entries.rs` — add API endpoints, extend `EntryPatchBody`, extend template data
|
||||
2. `secrets-mcp/templates/entries.html` — add relations column, edit dialog parent selector, view dialog children list
|
||||
3. Test: Web UI works end-to-end
|
||||
|
||||
### Phase 5: Export / Import (Optional)
|
||||
|
||||
1. `secrets-core/src/models.rs` — add `parents` to `ExportEntry`
|
||||
2. `secrets-core/src/service/export.rs` — populate parents
|
||||
3. `secrets-core/src/service/import.rs` — two-phase import with relation resolution
|
||||
|
||||
---
|
||||
|
||||
## Database Migration
|
||||
|
||||
Add to `secrets-core/src/db.rs` `migrate()`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (parent_entry_id, child_entry_id),
|
||||
CHECK (parent_entry_id <> child_entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||
```
|
||||
|
||||
This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next startup.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Same-user isolation**: `add_relation` must verify both `parent_entry_id` and `child_entry_id` belong to the same `user_id` (or both are `NULL` for legacy single-user mode)
|
||||
- **Cycle detection**: Recursive CTE query prevents any directed cycle, regardless of depth
|
||||
- **CASCADE delete**: When an entry is deleted, all its relation edges are automatically removed via the `ON DELETE CASCADE` foreign key. This is the same pattern used by `entry_secrets`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Metadata Search
|
||||
|
||||
- `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
|
||||
- `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
|
||||
- `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
|
||||
- `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
|
||||
- `metadata_query` with special characters (`%`, `_`) is properly escaped
|
||||
- Existing `query` parameter behavior is unchanged
|
||||
- Web filter bar preserves `metadata_query` across pagination and folder tab clicks
|
||||
|
||||
### Entry Relations
|
||||
|
||||
- Can add a parent→child relation between two entries
|
||||
- Can add multiple parents to a single entry
|
||||
- Cannot add self-referencing relation (CHECK constraint)
|
||||
- Cannot create a direct cycle (A→B→A)
|
||||
- Cannot create an indirect cycle (A→B→C→A)
|
||||
- Cannot link entries from different users
|
||||
- Deleting an entry removes all its relation edges but leaves related entries intact
|
||||
- MCP `secrets_add` with `parent_ids` creates relations
|
||||
- MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
|
||||
- MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
|
||||
- Web entry list shows relation counts
|
||||
- Web edit dialog allows adding/removing parents
|
||||
- Web entry view shows children with navigation links
|
||||
|
||||
55
plans/move-secret-management-to-view.md
Normal file
55
plans/move-secret-management-to-view.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 将编辑弹窗中的密文管理功能移到查看密文弹窗
|
||||
|
||||
## 当前状态
|
||||
|
||||
- **编辑弹窗**:密文重命名(input)、类型修改(select)、解绑(×按钮)、name 可用性校验
|
||||
- **查看密文弹窗**:解密后显示值、复制、显示/隐藏密码
|
||||
- **列表行**:密文 chips(name+type)+ 解绑按钮
|
||||
|
||||
## 变更内容
|
||||
|
||||
### 1. 编辑弹窗 — 移除密文区域
|
||||
|
||||
- 移除 HTML 中 `#edit-secrets-list` 所在的 `.modal-secrets` div(第559行)
|
||||
- 移除 JS 中 `renderEditSecrets`、`bindSecretValidation` 函数
|
||||
- 移除 `openEdit` 中读取/渲染 `data-entry-secrets` 的逻辑
|
||||
- 移除 `edit-save` 中 secret rename/type PATCH 逻辑
|
||||
- 移除编辑弹窗内的 unlink 事件监听器(第1492-1517行)
|
||||
- `refreshListAfterSave` 不再处理 secretRows 参数
|
||||
|
||||
### 2. 查看密文弹窗 — 增加管理功能
|
||||
|
||||
在每个解密字段行中增加:
|
||||
|
||||
- **重命名输入框**(inline edit,带 debounce 校验)
|
||||
- **类型下拉选择**
|
||||
- **解绑按钮**
|
||||
- **保存按钮**(逐行或统一保存)
|
||||
- 复用现有的 `PATCH /api/secrets/{id}` 和 `DELETE /api/entries/{entry_id}/secrets/{secret_id}` 接口
|
||||
|
||||
需要在 `openView` 中额外传入 `data-entry-secrets`(含 secret id/name/type),以便将管理功能与解密值关联。
|
||||
|
||||
### 3. 列表行 — 保留只读摘要
|
||||
|
||||
- 保留密文 chips 的 name + type 展示
|
||||
- **移除** chips 上的解绑按钮(×)
|
||||
- **移除**列表行的 unlink 事件监听器(第1466-1490行)
|
||||
|
||||
### 4. i18n 更新
|
||||
|
||||
- 为查看弹窗新增重命名、类型修改、解绑相关的中英文翻译
|
||||
- 清理编辑弹窗中不再需要的 i18n key
|
||||
|
||||
### 5. CSS 调整
|
||||
|
||||
- 查看弹窗中为管理控件添加样式(input/select/button 行内布局)
|
||||
|
||||
## 不涉及的变更
|
||||
|
||||
- 后端 API 无需修改(复用现有接口)
|
||||
- 版本 bump(视为前次 0.5.11 的一部分,tag 尚未被 CI 创建)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `crates/secrets-mcp/templates/entries.html`(HTML + JS + CSS)
|
||||
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API)
|
||||
178
plans/web-tags-filter.md
Normal file
178
plans/web-tags-filter.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Web 条目页 tags 筛选计划
|
||||
|
||||
## 目标
|
||||
|
||||
在 Web `/entries` 页面补齐 `tags` 筛选能力,使现有 `tags` 字段在 Web、MCP、数据层三者之间保持一致。
|
||||
|
||||
本次只做最小实现:支持用户在 Web 上输入 tags 条件并筛选条目,不改动数据库结构,不新增 MCP tool 参数,不改动条目编辑语义。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 数据层已支持 `tags` 过滤:`crates/secrets-core/src/service/search.rs`
|
||||
- MCP 已支持 `tags` 参数:`crates/secrets-mcp/src/tools.rs`
|
||||
- Web `/entries` 仅展示 tags 列与编辑字段,没有筛选入口:
|
||||
- 查询参数缺少 `tags`:`crates/secrets-mcp/src/web/entries.rs`
|
||||
- 模板筛选栏缺少 tags 输入:`crates/secrets-mcp/templates/entries.html`
|
||||
- Web 查询当前固定传 `tags: &[]`
|
||||
|
||||
## 范围
|
||||
|
||||
### 包含
|
||||
|
||||
- `/entries` 页面增加 tags 筛选输入
|
||||
- 后端将 tags 解析并传入 `SearchParams`
|
||||
- 分页、folder tabs、筛选重置后的 URL 状态保持一致
|
||||
- i18n 文案补齐
|
||||
|
||||
### 不包含
|
||||
|
||||
- MCP 工具改造
|
||||
- 数据库迁移或索引变更
|
||||
- `/trash` 页面筛选增强
|
||||
- 新增 tags 自动补全、标签选择器、标签管理页
|
||||
|
||||
## 交互定义
|
||||
|
||||
### 输入方式
|
||||
|
||||
- 在 `/entries` 筛选栏增加一个 `tags` 文本输入框
|
||||
- 输入格式采用逗号分隔,例如:`prod, aliyun`
|
||||
- 服务端按逗号拆分、`trim`、去掉空字符串
|
||||
|
||||
### 匹配语义
|
||||
|
||||
- 继续复用现有搜索层语义:`tags @> ARRAY[...]::text[]`
|
||||
- 即:用户输入多个 tags 时,要求条目同时包含这些 tags(AND 语义)
|
||||
|
||||
### 状态保持
|
||||
|
||||
- 筛选提交后,输入框保留原值
|
||||
- 分页上一页/下一页链接保留 `tags`
|
||||
- folder tabs 切换时保留 `tags`
|
||||
- `重置` 仍回到 `/entries`,清空所有筛选条件
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 扩展 Web 查询参数与模板上下文
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 在 `EntriesQuery` 中增加 `tags: Option<String>`
|
||||
- 在 `EntriesPageTemplate` 中增加 `filter_tags: String`
|
||||
- 在 `entries_page` 中读取原始 tags 字符串,用于模板回填
|
||||
- 将原始字符串解析为 `Vec<String>`,供 `SearchParams.tags` 使用
|
||||
|
||||
建议新增一个局部辅助函数,职责仅限于:
|
||||
|
||||
- 接收 `Option<&str>`
|
||||
- 按逗号分割
|
||||
- `trim`
|
||||
- 过滤空值
|
||||
- 返回 `Vec<String>`
|
||||
|
||||
保持逻辑局部化,避免把 tags 解析散落到多个位置。
|
||||
|
||||
### 2. 将 tags 传入条目查询与计数
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 更新 `count_params`,不再使用 `tags: &[]`
|
||||
- 更新 `list_params`,复用相同 tags 切片
|
||||
- 确保总数统计、分页列表与实际筛选条件一致
|
||||
|
||||
## 3. 让 folder tabs 计数遵循相同 tags 条件
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
当前 folder tabs 使用手写 SQL 统计各 folder 数量,需要同步加入 tags 条件,否则会出现:
|
||||
|
||||
- 列表已按 tags 过滤
|
||||
- 但 folder tab 数量仍是未过滤结果
|
||||
|
||||
实现方式:
|
||||
|
||||
- 在构建 `folder_sql` 时,当 tags 非空,追加 `tags @> ARRAY[...]::text[]`
|
||||
- 对应补齐 bind 参数
|
||||
- 保持与 `SearchParams` 的过滤语义完全一致
|
||||
|
||||
## 4. 让 URL 生成函数保留 tags
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 扩展 `entries_href(...)` 参数,加入 `tags: Option<&str>`
|
||||
- 在 folder tabs 链接中传入当前 tags
|
||||
- 在需要保留筛选状态的地方一并传递 tags
|
||||
|
||||
## 5. 更新模板筛选栏与分页链接
|
||||
|
||||
文件:`crates/secrets-mcp/templates/entries.html`
|
||||
|
||||
- 在筛选表单中新增 tags 输入框
|
||||
- 输入框 value 绑定 `filter_tags`
|
||||
- 为 tags 输入框增加 i18n label / placeholder
|
||||
- 分页链接 `上一页/下一页` 补充 `tags` query 参数
|
||||
|
||||
建议放置位置:名称与元数据值之间或元数据值与类型之间,保持现有筛选栏布局最小改动。
|
||||
|
||||
## 6. 补齐前端文案
|
||||
|
||||
文件:`crates/secrets-mcp/templates/entries.html`
|
||||
|
||||
新增 i18n key:
|
||||
|
||||
- `filterTagsLabel`
|
||||
- `filterTagsPlaceholder`
|
||||
|
||||
建议文案:
|
||||
|
||||
- zh-CN: `标签` / `多个标签用逗号分隔`
|
||||
- zh-Hant: `標籤` / `多個標籤用逗號分隔`
|
||||
- en: `Tags` / `Comma-separated tags`
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- 访问 `/entries?tags=prod` 时,只返回包含 `prod` 的条目
|
||||
- 访问 `/entries?tags=prod,aliyun` 时,只返回同时包含 `prod` 与 `aliyun` 的条目
|
||||
- tags 两侧空格不影响结果,例如 `prod, aliyun`
|
||||
- 空字符串、重复逗号不会报错,例如 `prod,,aliyun`
|
||||
- 分页后 `tags` 不丢失
|
||||
- 切换 folder tab 后 `tags` 不丢失
|
||||
- 重置后清空 `tags`
|
||||
|
||||
### 一致性验收
|
||||
|
||||
- 页面总数、列表内容、folder tab 数量使用同一组 tags 条件
|
||||
- Web 语义与 MCP / `SearchParams` 语义一致,均为 AND 匹配
|
||||
|
||||
## 风险与注意点
|
||||
|
||||
- folder tabs 计数 SQL 是手写的,最容易漏掉 tags 绑定顺序
|
||||
- `list_params` 基于 `count_params` 结构展开,注意借用生命周期不要引入临时值悬垂
|
||||
- 分页链接和 `entries_href` 若漏传 `tags`,用户会感觉筛选“偶尔失效”
|
||||
- 现阶段不做 tags 规范化;输入 `Prod` 与存储 `prod` 是否匹配,取决于数组元素本身是否完全一致
|
||||
|
||||
## 可选后续
|
||||
|
||||
如果上线后确认 `tags` 仍被频繁使用,可以继续做:
|
||||
|
||||
1. tags chip UI,而不是纯文本输入
|
||||
2. 常用 tags 自动补全
|
||||
3. 在 Web 过滤栏里明确提示“多个标签为同时匹配”
|
||||
4. 评估是否需要大小写规范化策略
|
||||
|
||||
## 验证建议
|
||||
|
||||
实现后至少执行:
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
如果本次提交涉及 `crates/**`,按仓库规则在提交前再执行:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Run against prod BEFORE deploying secrets-mcp with FK migration.
|
||||
-- Requires: write access to SECRETS_DATABASE_URL.
|
||||
-- Example: psql "$SECRETS_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/cleanup-orphan-user-ids.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE entries
|
||||
SET user_id = NULL
|
||||
WHERE user_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries.user_id);
|
||||
|
||||
UPDATE entries_history
|
||||
SET user_id = NULL
|
||||
WHERE user_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries_history.user_id);
|
||||
|
||||
UPDATE audit_log
|
||||
SET user_id = NULL
|
||||
WHERE user_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = audit_log.user_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,194 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- migrate-v0.3.0.sql
|
||||
-- Schema migration from v0.2.x → v0.3.0
|
||||
--
|
||||
-- Changes:
|
||||
-- • entries: namespace → folder, kind → type; add notes column
|
||||
-- • audit_log: namespace → folder, kind → type
|
||||
-- • entries_history: namespace → folder, kind → type; add user_id column
|
||||
-- • Unique index: (user_id, name) → (user_id, folder, name)
|
||||
-- Same name in different folders is now allowed; no rename needed.
|
||||
--
|
||||
-- Safe to run multiple times (fully idempotent).
|
||||
-- Preserves all data in users, entries, secrets.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── entries: rename namespace→folder, kind→type ──────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries' AND column_name = 'namespace'
|
||||
) THEN
|
||||
ALTER TABLE entries RENAME COLUMN namespace TO folder;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries' AND column_name = 'kind'
|
||||
) THEN
|
||||
ALTER TABLE entries RENAME COLUMN kind TO type;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Set NOT NULL + default for folder/type in entries
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries' AND column_name = 'folder'
|
||||
) THEN
|
||||
UPDATE entries SET folder = '' WHERE folder IS NULL;
|
||||
ALTER TABLE entries ALTER COLUMN folder SET NOT NULL;
|
||||
ALTER TABLE entries ALTER COLUMN folder SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries' AND column_name = 'type'
|
||||
) THEN
|
||||
UPDATE entries SET type = '' WHERE type IS NULL;
|
||||
ALTER TABLE entries ALTER COLUMN type SET NOT NULL;
|
||||
ALTER TABLE entries ALTER COLUMN type SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add notes column to entries if missing
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- ── audit_log: rename namespace→folder, kind→type ────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'audit_log' AND column_name = 'namespace'
|
||||
) THEN
|
||||
ALTER TABLE audit_log RENAME COLUMN namespace TO folder;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'audit_log' AND column_name = 'kind'
|
||||
) THEN
|
||||
ALTER TABLE audit_log RENAME COLUMN kind TO type;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'audit_log' AND column_name = 'folder'
|
||||
) THEN
|
||||
UPDATE audit_log SET folder = '' WHERE folder IS NULL;
|
||||
ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL;
|
||||
ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'audit_log' AND column_name = 'type'
|
||||
) THEN
|
||||
UPDATE audit_log SET type = '' WHERE type IS NULL;
|
||||
ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL;
|
||||
ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
|
||||
|
||||
-- ── entries_history: rename namespace→folder, kind→type; add user_id ─────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries_history' AND column_name = 'namespace'
|
||||
) THEN
|
||||
ALTER TABLE entries_history RENAME COLUMN namespace TO folder;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries_history' AND column_name = 'kind'
|
||||
) THEN
|
||||
ALTER TABLE entries_history RENAME COLUMN kind TO type;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries_history' AND column_name = 'folder'
|
||||
) THEN
|
||||
UPDATE entries_history SET folder = '' WHERE folder IS NULL;
|
||||
ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL;
|
||||
ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'entries_history' AND column_name = 'type'
|
||||
) THEN
|
||||
UPDATE entries_history SET type = '' WHERE type IS NULL;
|
||||
ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL;
|
||||
ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
|
||||
|
||||
-- ── secrets_history: drop actor column ───────────────────────────────────────
|
||||
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
||||
|
||||
-- ── Rebuild unique indexes: (user_id, folder, name) ──────────────────────────
|
||||
-- Note: folder is now part of the key, so same name in different folders is
|
||||
-- naturally distinct — no rename of existing rows needed.
|
||||
DROP INDEX IF EXISTS idx_entries_unique_legacy;
|
||||
DROP INDEX IF EXISTS idx_entries_unique_user;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||
ON entries(folder, name)
|
||||
WHERE user_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
||||
ON entries(user_id, folder, name)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
-- ── Replace old namespace/kind indexes with folder/type ──────────────────────
|
||||
DROP INDEX IF EXISTS idx_entries_namespace;
|
||||
DROP INDEX IF EXISTS idx_entries_kind;
|
||||
DROP INDEX IF EXISTS idx_audit_log_ns_kind;
|
||||
DROP INDEX IF EXISTS idx_entries_history_ns_kind_name;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_folder
|
||||
ON entries(folder) WHERE folder <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_type
|
||||
ON entries(type) WHERE type <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_user_id
|
||||
ON entries(user_id) WHERE user_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
|
||||
ON audit_log(folder, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
||||
ON entries_history(folder, type, name, version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
|
||||
ON entries_history(user_id) WHERE user_id IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification queries (run these manually to confirm) ─────────────────────
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_name = 'entries' ORDER BY ordinal_position;
|
||||
-- SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'entries';
|
||||
-- SELECT COUNT(*) FROM entries;
|
||||
-- SELECT COUNT(*) FROM users;
|
||||
-- SELECT COUNT(*) FROM secrets;
|
||||
@@ -5,13 +5,39 @@ set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
# ── 版本解析 ──────────────────────────────────────────────────────────────
|
||||
version="$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
tag="secrets-mcp-${version}"
|
||||
|
||||
echo "==> 当前 secrets-mcp 版本: ${version}"
|
||||
echo "==> 检查是否已存在 tag: ${tag}"
|
||||
|
||||
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
|
||||
# ── 版本 bump 硬检查 ──────────────────────────────────────────────────────
|
||||
# 若工作区存在 crates/** 或 Cargo.toml/Cargo.lock 变更,且版本号与父提交一致,则视为未发版,直接失败。
|
||||
has_code_changes=false
|
||||
diff_stat="$(jj diff --stat 2>/dev/null || true)"
|
||||
if [ -n "$diff_stat" ]; then
|
||||
# 仅 crates/ 或根 Cargo.toml 变更视为行为变更;Cargo.lock 为构建产物,不触发版本检查
|
||||
if echo "$diff_stat" | grep -qE 'crates/|^[^ ]*Cargo\.toml'; then
|
||||
has_code_changes=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$has_code_changes" = true ]; then
|
||||
parent_version="$(jj file show --revision @- crates/secrets-mcp/Cargo.toml 2>/dev/null | grep -m1 '^version' | sed 's/.*"\(.*\)".*/\1/' || true)"
|
||||
if [ -z "$parent_version" ]; then
|
||||
# 无法读取父版本(例如初始提交),跳过此检查
|
||||
echo "==> 无法读取父提交版本,跳过 bump 检查"
|
||||
elif [ "$version" = "$parent_version" ]; then
|
||||
echo "==> 错误: 工作区包含 crates/ 或 Cargo 变更,但版本号未 bump(${version} == ${parent_version})"
|
||||
echo " 按规则,每次代码变更必须 bump crates/secrets-mcp/Cargo.toml 中的 version。"
|
||||
exit 1
|
||||
else
|
||||
echo "==> 版本已 bump: ${parent_version} → ${version}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> 检查是否已存在 tag: ${tag}"
|
||||
if jj log --no-graph --revisions "tag(${tag})" --limit 1 >/dev/null 2>&1; then
|
||||
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
|
||||
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
|
||||
else
|
||||
|
||||
1
scripts/repair-secrets.template.csv
Normal file
1
scripts/repair-secrets.template.csv
Normal file
@@ -0,0 +1 @@
|
||||
entry_id,secret_name,secret_value
|
||||
|
383
scripts/repair_secrets_from_csv.py
Normal file
383
scripts/repair_secrets_from_csv.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch re-encrypt secret fields from a CSV file.
|
||||
|
||||
CSV format:
|
||||
entry_id,secret_name,secret_value
|
||||
019d...,api_key,sk-xxxx
|
||||
019d...,password,hunter2
|
||||
|
||||
The script groups rows by entry_id, then calls `secrets_update` with `secrets_obj`
|
||||
so the server re-encrypts the provided plaintext values with the current key.
|
||||
|
||||
Warnings:
|
||||
- Keep the CSV outside version control whenever possible.
|
||||
- Delete the filled CSV after the repair is complete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_USER_AGENT = "Cursor/3.0.12 (darwin arm64)"
|
||||
REQUIRED_COLUMNS = {"entry_id", "secret_name", "secret_value"}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Repair secret ciphertexts by re-submitting plaintext via secrets_update."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
required=True,
|
||||
help="Path to CSV file with columns: entry_id,secret_name,secret_value",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mcp-json",
|
||||
default=str(Path.home() / ".cursor" / "mcp.json"),
|
||||
help="Path to mcp.json used to resolve URL and headers",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
default="secrets",
|
||||
help="MCP server name inside mcp.json (default: secrets)",
|
||||
)
|
||||
parser.add_argument("--url", help="Override MCP URL")
|
||||
parser.add_argument("--auth", help="Override Authorization header value")
|
||||
parser.add_argument("--encryption-key", help="Override X-Encryption-Key header value")
|
||||
parser.add_argument(
|
||||
"--user-agent",
|
||||
default=DEFAULT_USER_AGENT,
|
||||
help=f"User-Agent header (default: {DEFAULT_USER_AGENT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Parse and print grouped updates without sending requests",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_mcp_config(path: str, server_name: str) -> dict[str, Any]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
servers = data.get("mcpServers", {})
|
||||
if server_name not in servers:
|
||||
raise KeyError(f"Server '{server_name}' not found in {path}")
|
||||
return servers[server_name]
|
||||
|
||||
|
||||
def resolve_connection_settings(args: argparse.Namespace) -> tuple[str, str, str]:
|
||||
server = load_mcp_config(args.mcp_json, args.server)
|
||||
headers = server.get("headers", {})
|
||||
|
||||
url = args.url or server.get("url")
|
||||
auth = args.auth or headers.get("Authorization")
|
||||
encryption_key = args.encryption_key or headers.get("X-Encryption-Key")
|
||||
|
||||
if not url:
|
||||
raise ValueError("Missing MCP URL. Pass --url or configure it in mcp.json.")
|
||||
if not auth:
|
||||
raise ValueError(
|
||||
"Missing Authorization header. Pass --auth or configure it in mcp.json."
|
||||
)
|
||||
if not encryption_key:
|
||||
raise ValueError(
|
||||
"Missing X-Encryption-Key. Pass --encryption-key or configure it in mcp.json."
|
||||
)
|
||||
|
||||
return url, auth, encryption_key
|
||||
|
||||
|
||||
def load_updates(csv_path: str) -> OrderedDict[str, OrderedDict[str, str]]:
|
||||
grouped: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
|
||||
|
||||
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
fieldnames = set(reader.fieldnames or [])
|
||||
missing = REQUIRED_COLUMNS - fieldnames
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"CSV missing required columns: " + ", ".join(sorted(missing))
|
||||
)
|
||||
|
||||
for line_no, row in enumerate(reader, start=2):
|
||||
entry_id = (row.get("entry_id") or "").strip()
|
||||
secret_name = (row.get("secret_name") or "").strip()
|
||||
secret_value = row.get("secret_value") or ""
|
||||
|
||||
if not entry_id and not secret_name and not secret_value:
|
||||
continue
|
||||
if not entry_id:
|
||||
raise ValueError(f"Line {line_no}: entry_id is required")
|
||||
if not secret_name:
|
||||
raise ValueError(f"Line {line_no}: secret_name is required")
|
||||
|
||||
entry_group = grouped.setdefault(entry_id, OrderedDict())
|
||||
if secret_name in entry_group:
|
||||
raise ValueError(
|
||||
f"Line {line_no}: duplicate secret_name '{secret_name}' for entry_id '{entry_id}'"
|
||||
)
|
||||
entry_group[secret_name] = secret_value
|
||||
|
||||
if not grouped:
|
||||
raise ValueError("CSV contains no updates")
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def post_json(
|
||||
url: str,
|
||||
payload: dict[str, Any],
|
||||
auth: str,
|
||||
encryption_key: str,
|
||||
user_agent: str,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[int, str | None, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Authorization": auth,
|
||||
"X-Encryption-Key": encryption_key,
|
||||
"User-Agent": user_agent,
|
||||
}
|
||||
if session_id:
|
||||
headers["mcp-session-id"] = session_id
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return (
|
||||
resp.status,
|
||||
resp.headers.get("mcp-session-id") or session_id,
|
||||
resp.read().decode("utf-8"),
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
return exc.code, session_id, body
|
||||
|
||||
|
||||
def parse_sse_json(body: str) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for line in body.splitlines():
|
||||
if line.startswith("data: {"):
|
||||
items.append(json.loads(line[6:]))
|
||||
return items
|
||||
|
||||
|
||||
def initialize_session(
|
||||
url: str, auth: str, encryption_key: str, user_agent: str
|
||||
) -> str:
|
||||
status, session_id, body = post_json(
|
||||
url,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "repair-script", "version": "1.0"},
|
||||
},
|
||||
},
|
||||
auth,
|
||||
encryption_key,
|
||||
user_agent,
|
||||
)
|
||||
if status != 200 or not session_id:
|
||||
raise RuntimeError(f"initialize failed: status={status}, body={body[:500]}")
|
||||
|
||||
status, _, body = post_json(
|
||||
url,
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
||||
auth,
|
||||
encryption_key,
|
||||
user_agent,
|
||||
session_id,
|
||||
)
|
||||
if status not in (200, 202):
|
||||
raise RuntimeError(
|
||||
f"notifications/initialized failed: status={status}, body={body[:500]}"
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def load_entry_index(
|
||||
url: str, auth: str, encryption_key: str, user_agent: str, session_id: str
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
status, _, body = post_json(
|
||||
url,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 999_001,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "secrets_find",
|
||||
"arguments": {
|
||||
"limit": 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
auth,
|
||||
encryption_key,
|
||||
user_agent,
|
||||
session_id,
|
||||
)
|
||||
items = parse_sse_json(body)
|
||||
last = items[-1] if items else {"raw": body[:1000]}
|
||||
if status != 200:
|
||||
raise RuntimeError(
|
||||
f"secrets_find failed: status={status}, body={body[:500]}"
|
||||
)
|
||||
if "error" in last:
|
||||
raise RuntimeError(f"secrets_find returned error: {last}")
|
||||
|
||||
content = last.get("result", {}).get("content", [])
|
||||
if not content:
|
||||
raise RuntimeError("secrets_find returned no content")
|
||||
payload = json.loads(content[0]["text"])
|
||||
|
||||
index: dict[str, tuple[str, str]] = {}
|
||||
for entry in payload.get("entries", []):
|
||||
entry_id = entry.get("id")
|
||||
name = entry.get("name")
|
||||
folder = entry.get("folder", "")
|
||||
if entry_id and name is not None:
|
||||
index[entry_id] = (name, folder)
|
||||
return index
|
||||
|
||||
|
||||
def call_secrets_update(
|
||||
url: str,
|
||||
auth: str,
|
||||
encryption_key: str,
|
||||
user_agent: str,
|
||||
session_id: str,
|
||||
request_id: int,
|
||||
entry_id: str,
|
||||
entry_name: str,
|
||||
entry_folder: str,
|
||||
secrets_obj: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "secrets_update",
|
||||
"arguments": {
|
||||
"id": entry_id,
|
||||
"name": entry_name,
|
||||
"folder": entry_folder,
|
||||
"secrets_obj": secrets_obj,
|
||||
# Pass the key as an argument too, so repair can still work
|
||||
# even when a client/proxy mishandles custom headers.
|
||||
"encryption_key": encryption_key,
|
||||
},
|
||||
},
|
||||
}
|
||||
status, _, body = post_json(
|
||||
url, payload, auth, encryption_key, user_agent, session_id
|
||||
)
|
||||
items = parse_sse_json(body)
|
||||
last = items[-1] if items else {"raw": body[:1000]}
|
||||
if status != 200:
|
||||
raise RuntimeError(
|
||||
f"secrets_update failed for {entry_id}: status={status}, body={body[:500]}"
|
||||
)
|
||||
return last
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
try:
|
||||
url, auth, encryption_key = resolve_connection_settings(args)
|
||||
updates = load_updates(args.csv)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Loaded {len(updates)} entries from {args.csv}")
|
||||
|
||||
if args.dry_run:
|
||||
for entry_id, secrets_obj in updates.items():
|
||||
print(
|
||||
json.dumps(
|
||||
{"id": entry_id, "secrets_obj": secrets_obj},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
session_id = initialize_session(url, auth, encryption_key, args.user_agent)
|
||||
entry_index = load_entry_index(
|
||||
url, auth, encryption_key, args.user_agent, session_id
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
success = 0
|
||||
failures = 0
|
||||
for request_id, (entry_id, secrets_obj) in enumerate(updates.items(), start=2):
|
||||
try:
|
||||
if entry_id not in entry_index:
|
||||
raise RuntimeError(
|
||||
f"entry id not found in secrets_find results: {entry_id}"
|
||||
)
|
||||
entry_name, entry_folder = entry_index[entry_id]
|
||||
result = call_secrets_update(
|
||||
url,
|
||||
auth,
|
||||
encryption_key,
|
||||
args.user_agent,
|
||||
session_id,
|
||||
request_id,
|
||||
entry_id,
|
||||
entry_name,
|
||||
entry_folder,
|
||||
secrets_obj,
|
||||
)
|
||||
if "error" in result:
|
||||
failures += 1
|
||||
print(
|
||||
json.dumps(
|
||||
{"id": entry_id, "status": "error", "result": result},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
success += 1
|
||||
print(
|
||||
json.dumps(
|
||||
{"id": entry_id, "status": "ok", "result": result},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
print(f"{entry_id}: ERROR: {exc}", file=sys.stderr)
|
||||
|
||||
print(f"Done. success={success} failure={failures}")
|
||||
return 0 if failures == 0 else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user