Compare commits

...

18 Commits

Author SHA1 Message Date
voson
b0fcb83592 release(secrets-mcp): 0.5.9 — users.key_version 与会话失效;Web 条目解密 API 与列表增强
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m24s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-06 17:23:20 +08:00
voson
8942718641 scripts: 添加基于 CSV 的 MCP secrets 重加密修复工具
通过读取 entry_id/secret_name/secret_value 调用 secrets_update 让服务端用当前密钥重加密。附带模板 CSV,.gitignore 忽略 *.pyc。
2026-04-06 16:38:37 +08:00
voson
53d53ff96a release(secrets-mcp): 0.5.8 — 修复更换密码短语流程
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- secrets-core: change_user_key() 事务内全量解密并重加密 secrets
- web: POST /api/key-change;已有密钥时拒绝 POST /api/key-setup(409)
- dashboard: 更换密码需当前密码,调用 key-change
- 同步 Cargo.lock
2026-04-06 12:04:35 +08:00
voson
cab234cfcb feat(secrets-mcp): 增强 MCP 请求日志与 encryption_key 参数支持
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m28s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
logging.rs:
- 每条 MCP POST 日志新增 auth_key(Bearer token 前12字符掩码)、
  enc_key(X-Encryption-Key 前4后4字符指纹,如 146b…5516(64) 或 absent)、
  user_id、tool_args(白名单非敏感参数摘要)字段
- 新增辅助函数 mask_bearer / mask_enc_key / extract_tool_args / summarize_value

tools.rs:
- extract_enc_key 成功路径增加 debug 级指纹日志(raw_len/trimmed_len/prefix/suffix)
- 新增 extract_enc_key_or_arg / require_user_and_key_or_arg:优先使用参数传入的密钥,
  fallback 到 X-Encryption-Key 头,绕过 Cursor Chat MCP 头透传异常
- GetSecretInput / AddInput / UpdateInput / ExportInput / EnvMapInput 各增加可选
  encryption_key 字段,对应工具实现改用 require_user_and_key_or_arg
2026-04-06 11:03:01 +08:00
voson
e0fee639c1 release(secrets-mcp): 0.5.7
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-05 17:07:31 +08:00
voson
7c53bfb782 feat(core): entry 历史附带关联 secret 密文快照,rollback 可恢复 N:N 与密文
- db: metadata_with_secret_snapshot / strip / parse 辅助
- add/update/delete/rollback 在写 entries_history 前合并快照
- rollback: 按历史快照同步 entry_secrets、更新或插入 secrets
- 满足 clippy collapsible_if
2026-04-05 17:06:53 +08:00
voson
63cb3a8216 release(secrets-mcp): 0.5.6
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
修复 OAuth 解绑时非法聚合 FOR UPDATE,Web OAuth 审计 IP 与 TRUST_PROXY 对齐并校验 IP,账号绑定写入 oauth_state 失败时回滚 bind 标记。回滚条目时恢复 folder/type,导入冲突检查在 DB 失败时传播错误,MCP delete/history 要求已登录用户,全局请求体 10MiB 限制。CI 部署支持 DEPLOY_KNOWN_HOSTS,默认 accept-new;文档与 deploy 示例补充连接池、限流、TRUST_PROXY。移除含明文凭据的 sync-test-to-prod 脚本。
2026-04-05 15:29:03 +08:00
voson
2b994141b8 release(secrets-mcp): 0.5.5 — 生产 CORS 显式 allow_methods,修复 tower-http 启动 panic
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
credentials + wildcard methods/headers 被 tower-http 禁止;生产环境改为 GET/POST/PATCH/DELETE/OPTIONS 白名单。
2026-04-05 12:27:40 +08:00
voson
9d6ac5c13a release(secrets-mcp): 0.5.4 — Web 分页修正与 hex 解码;批量删除上限;MCP @ 路径检测
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
2026-04-05 12:14:40 +08:00
voson
1860cce86c release(secrets-mcp): 0.5.3 — 审计日志分页与 Web;CONTRIBUTING;文档与模板修正 2026-04-05 11:34:04 +08:00
dd24f7cc44 release: secrets-mcp 0.5.2
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Bump version: secrets-mcp-0.5.1 tag already existed while crates had further changes.

Made-with: Cursor
2026-04-05 10:38:50 +08:00
voson
aefad33870 chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m22s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库
- MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示
- 新增 deser 单元测试;README/AGENTS 与 models 注释同步

Made-with: Cursor
2026-04-04 21:27:33 +08:00
voson
0ffb81e57f feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
2026-04-04 20:30:32 +08:00
voson
4a1654c820 docs: update MCP tools list, env vars, taxonomy and deploy structure 2026-04-04 18:04:35 +08:00
voson
a15e2eaf4a docs: align README with removed SQL migration scripts
Made-with: Cursor
2026-04-04 17:58:26 +08:00
voson
1518388374 chore(release): secrets-mcp 0.4.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Bump version for the N:N entry_secrets data model and related MCP/Web
changes. Remove superseded SQL migration artifacts; rely on auto-migrate.
Add structured errors, taxonomy normalization, and web i18n helpers.

Made-with: Cursor
2026-04-04 17:58:12 +08:00
b99d821644 Merge pull request 'refactor/entry-secret-nn' (#1) from refactor/entry-secret-nn into main
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m42s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Reviewed-on: #1
2026-04-03 19:44:47 +08:00
voson
32f275f88a feat(secrets-mcp): bump 0.3.9 and normalize listen address log
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Prepare a new release version and improve startup log readability by showing localhost for loopback bind addresses without changing runtime binding behavior.

Made-with: Cursor
2026-04-03 19:36:12 +08:00
49 changed files with 5732 additions and 1554 deletions

View File

@@ -208,6 +208,7 @@ jobs:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }} DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: | run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY" echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
@@ -216,19 +217,26 @@ jobs:
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
chmod 600 /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}" \ "/tmp/artifact/${MCP_BINARY}" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new" "${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 mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp
sudo chmod +x /opt/secrets-mcp/secrets-mcp sudo chmod +x /opt/secrets-mcp/secrets-mcp
sudo systemctl restart secrets-mcp sudo systemctl restart secrets-mcp
sleep 2 sleep 2
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1) sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
" "
rm -f /tmp/deploy_key
- name: 飞书通知 - name: 飞书通知
if: always() if: always()

7
.gitignore vendored
View File

@@ -2,7 +2,8 @@
.env .env
.DS_Store .DS_Store
.cursor/ .cursor/
# Google OAuth 下载的 JSON 凭据文件
client_secret_*.apps.googleusercontent.com.json
*.pem *.pem
tmp/ tmp/
client_secret_*.apps.googleusercontent.com.json
node_modules/
*.pyc

3
.vscode/tasks.json vendored
View File

@@ -22,7 +22,6 @@
"label": "test: workspace", "label": "test: workspace",
"type": "shell", "type": "shell",
"command": "cargo test --workspace --locked", "command": "cargo test --workspace --locked",
"dependsOn": "build",
"group": { "kind": "test", "isDefault": true } "group": { "kind": "test", "isDefault": true }
}, },
{ {
@@ -35,7 +34,7 @@
"label": "clippy: workspace", "label": "clippy: workspace",
"type": "shell", "type": "shell",
"command": "cargo clippy --workspace --locked -- -D warnings", "command": "cargo clippy --workspace --locked -- -D warnings",
"dependsOn": "build" "problemMatcher": []
}, },
{ {
"label": "ci: release-check", "label": "ci: release-check",

View File

@@ -2,12 +2,37 @@
本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp` 本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
## 版本控制
本仓库使用 **[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/CDGitea Actions仍通过 Git 协议拉取代码Runner 侧自动使用 `git`,无需修改
- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse`
## 提交 / 推送硬规则(优先于下文) ## 提交 / 推送硬规则(优先于下文)
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:** **每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`。若当前版本对应 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` 3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
## 项目结构 ## 项目结构
@@ -55,13 +80,24 @@ entries (
```sql ```sql
secrets ( secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, user_id UUID,
field_name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
type VARCHAR(64) NOT NULL DEFAULT 'text',
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1, version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
UNIQUE(entry_id, field_name) )
-- 唯一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)
) )
``` ```
@@ -101,24 +137,29 @@ oauth_accounts (
### MCP 消歧AI 调用) ### 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` | | `folder` | 隔离空间(参与唯一键) | `refining` |
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` | | `type` | 软分类(不参与唯一键,用户自定义 | `server`, `service`, `account`, `person`, `document` |
| `name` | 标识名 | `gitea`, `aliyun` | | `name` | 标识名 | `gitea`, `aliyun` |
| `notes` | 非敏感说明 | 自由文本 | | `notes` | 非敏感说明 | 自由文本 |
| `tags` | 标签 | `["aliyun","prod"]` | | `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` | | `metadata` | 明文描述 | `ip``url``subtype` |
| `secrets.field_name` | 加密字段名(明文 | `token`, `ssh_key` | | `secrets.name` | 密钥名称(调用方提供 | `token`, `ssh_key`, `password` |
| `secrets.type` | 密钥类型(调用方提供,默认 `text` | `text`, `password`, `key` |
| `secrets.encrypted` | 密文 | AES-GCM | | `secrets.encrypted` | 密文 | AES-GCM |
### PEM 共享(`key_ref` ### 共享密钥N:N 关联
建议将共享 PEM 存为 **`type=key`** 的 entry其它记录在 `metadata.key_ref` 指向目标 entry 的 `name`(支持 `folder/name` 格式消歧)。删除被引用 key 时,服务会自动迁移为单副本 + 重定向(复制到首个引用方,其余引用方改指向新 owner解析逻辑见 `secrets_core::service::env_map` 多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联
添加条目时通过 `link_secret_names` 参数指定要关联的已有 secret name`(user_id, name)` 精确匹配)。
删除 entry 时仅解除关联secret 本身若仍被引用则保留;不再被任何 entry 引用时自动清理。
## 代码规范 ## 代码规范
@@ -130,6 +171,14 @@ oauth_accounts (
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。 - 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。 - MCPtools 参数与 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 ```bash
@@ -148,7 +197,7 @@ cargo test --locked
```bash ```bash
grep '^version' crates/secrets-mcp/Cargo.toml grep '^version' crates/secrets-mcp/Cargo.toml
git tag -l 'secrets-mcp-*' jj tag list
``` ```
## CI/CD ## CI/CD
@@ -166,9 +215,19 @@ git tag -l 'secrets-mcp-*'
| 变量 | 说明 | | 变量 | 说明 |
|------|------| |------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | | `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`。 | | `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 |
| `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` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 > `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

55
CONTRIBUTING.md Normal file
View 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) 的「提交 / 推送硬规则」章节。

136
Cargo.lock generated
View File

@@ -464,6 +464,20 @@ dependencies = [
"syn", "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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -596,6 +610,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -687,6 +707,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.32"
@@ -765,6 +791,35 @@ dependencies = [
"polyval", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -773,7 +828,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash", "foldhash 0.1.5",
] ]
[[package]] [[package]]
@@ -781,6 +836,11 @@ name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -1283,6 +1343,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1463,6 +1529,12 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -1506,6 +1578,21 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1658,6 +1745,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1953,6 +2049,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
"chrono", "chrono",
"hex",
"rand 0.10.0", "rand 0.10.0",
"serde", "serde",
"serde_json", "serde_json",
@@ -1960,6 +2057,7 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
@@ -1968,7 +2066,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.3.8" version = "0.5.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",
@@ -1976,6 +2074,7 @@ dependencies = [
"axum-extra", "axum-extra",
"chrono", "chrono",
"dotenvy", "dotenvy",
"governor",
"http", "http",
"rand 0.10.0", "rand 0.10.0",
"reqwest", "reqwest",
@@ -1994,6 +2093,7 @@ dependencies = [
"tower-sessions-sqlx-store-chrono", "tower-sessions-sqlx-store-chrono",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"urlencoding", "urlencoding",
"uuid", "uuid",
] ]
@@ -2194,6 +2294,15 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@@ -2716,6 +2825,7 @@ dependencies = [
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"iri-string", "iri-string",
"pin-project-lite", "pin-project-lite",
"tower", "tower",
@@ -3166,6 +3276,28 @@ dependencies = [
"wasite", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"

View File

@@ -28,6 +28,7 @@ rand = "^0.10.0"
# Utils # Utils
anyhow = "^1.0.102" anyhow = "^1.0.102"
thiserror = "^2"
chrono = { version = "^0.4.44", features = ["serde"] } chrono = { version = "^0.4.44", features = ["serde"] }
uuid = { version = "^1.22.0", features = ["serde"] } uuid = { version = "^1.22.0", features = ["serde"] }
tracing = "^0.1" tracing = "^0.1"

View File

@@ -25,6 +25,13 @@ cargo build --release -p secrets-mcp
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1: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、勿打入二进制。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 | | `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 ```bash
cargo run -p secrets-mcp cargo run -p secrets-mcp
@@ -54,10 +61,31 @@ SECRETS_ENV=production
条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun``ricnsmart/aliyun`)。 条目在逻辑上以 **`(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` | 工具 | 需要加密密钥 | 说明 |
- **共享 key 自动迁移删除**:删除仍被 `metadata.key_ref` 引用的 key 条目时,系统会自动迁移:把密文复制到首个引用方,并将其余引用方的 `key_ref` 重定向到新 owner然后继续删除。 |------|-------------|------|
| `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``name`+`folder` 定位 |
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
| `secrets_env_map` | 是 | 将 secrets 转换为环境变量映射(`UPPER(entry)_UPPER(field)` 格式),支持 `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 ## 加密架构(混合 E2EE
@@ -151,25 +179,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`**。首次连库自动迁移建表(`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 | folder | 组织/隔离空间,如 `refining``ricnsmart`;参与唯一键 |
| entries | type | 软分类,如 `server``service``key``person`可扩展,不参与唯一键) | | entries | type | 软分类,用户自定义,`server``service``account``person``document`(不参与唯一键) |
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 | | entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
| entries | notes | 非敏感说明文本 | | entries | notes | 非敏感说明文本 |
| entries | metadata | 明文 JSONip、url、`key_ref` 等) | | entries | metadata | 明文 JSONip、url、subtype 等) |
| secrets | field_name | 明文字段名,便于 schema 展示 | | secrets | name | 密钥名称(调用方提供) |
| secrets | type | 密钥类型(调用方提供,默认 `text` |
| secrets | encrypted | AES-GCM 密文(含 nonce | | secrets | encrypted | AES-GCM 密文(含 nonce |
| users | key_salt | PBKDF2 salt32B首次设置密码短语时写入 | | users | key_salt | PBKDF2 salt32B首次设置密码短语时写入 |
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 | | users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` | | users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
### PEM 共享(`key_ref` ### 共享密钥N:N 关联
同一 PEM 可被多条 `server` 等记录引用:建议将 PEM 存为 **`type=key`** 的 entry在其它条目的 `metadata.key_ref` 中写目标 entry 的 `name`(支持 `folder/name` 格式消歧);轮换时只更新该目标记录即可。 多个条目可共享同一密文字段,通过 `entry_secrets` 中间表实现 N:N 关联:
删除共享 key 时,系统会自动迁移引用:将密文复制到首个引用方(单副本),其余引用方的 `key_ref` 自动重定向到该新 owner再删除原 key 记录。 - 添加条目时可通过 `link_secret_names` 参数关联已有的 secret`(user_id, name)` 精确匹配查找)
- 同一 secret 可被多个 entry 引用,删除某 entry 不会级联删除被共享的 secret
- 当 secret 不再被任何 entry 引用时,自动清理(`NOT EXISTS` 子查询)
### 类型Type
`type` 字段用于软分类,由用户自由填写,不做任何自动转换或归一化。常见示例:`server``service``account``person``document`,但任何值均可接受。
## 审计日志 ## 审计日志
@@ -188,10 +223,18 @@ LIMIT 20;
``` ```
Cargo.toml Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service crates/secrets-core/ # db / crypto / models / audit / service
src/
taxonomy.rs # SECRET_TYPE_OPTIONSsecret 字段类型下拉选项)
service/ # 业务逻辑add, search, update, delete, export, env_map 等)
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
scripts/ scripts/
migrate-v0.3.0.sql # 可选:手动 SQL 迁移namespace/kind → folder/type、唯一键含 folder release-check.sh # 发版前 fmt / clippy / test
deploy/ # systemd、.env 示例 setup-gitea-actions.sh
sync-test-to-prod.sh # 测试库同步到生产(按需)
deploy/
.env.example # 环境变量模板
secrets-mcp.service # systemd 服务文件(生产部署用)
postgres-tls-hardening.md # PostgreSQL TLS 加固运维手册
``` ```
## CI/CDGitea Actions ## CI/CDGitea Actions

View File

@@ -10,7 +10,9 @@ path = "src/lib.rs"
[dependencies] [dependencies]
aes-gcm.workspace = true aes-gcm.workspace = true
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true chrono.workspace = true
hex = "0.4"
rand.workspace = true rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View File

@@ -5,6 +5,8 @@ use aes_gcm::{
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde_json::Value; use serde_json::Value;
use crate::error::AppError;
const NONCE_LEN: usize = 12; const NONCE_LEN: usize = 12;
// ─── AES-256-GCM encrypt / decrypt ─────────────────────────────────────────── // ─── 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); let nonce = Nonce::from_slice(nonce_bytes);
cipher cipher
.decrypt(nonce, ciphertext) .decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data")) .map_err(|_| AppError::DecryptionFailed.into())
} }
// ─── JSON helpers ───────────────────────────────────────────────────────────── // ─── 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. /// 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]> { 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 { if bytes.len() != 32 {
bail!( bail!(
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes", "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 ─────────────────────────────────────────────────────── // ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex { pub mod hex {
use anyhow::{Result, bail}; use anyhow::Result;
pub fn encode_hex(bytes: &[u8]) -> String { pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect() bytes.iter().map(|b| format!("{:02x}", b)).collect()
} }
pub fn decode_hex(s: &str) -> Result<Vec<u8>> { pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim(); Ok(::hex::decode(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()
} }
} }

View File

@@ -1,7 +1,7 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::Value; use serde_json::{Map, Value};
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
@@ -36,12 +36,31 @@ fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> { pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
tracing::debug!("connecting to database"); tracing::debug!("connecting to database");
let connect_options = build_connect_options(config)?; 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() let pool = PgPoolOptions::new()
.max_connections(10) .max_connections(max_connections)
.acquire_timeout(std::time::Duration::from_secs(5)) .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) .connect_with(connect_options)
.await?; .await?;
tracing::debug!("database connection established");
tracing::debug!(
max_connections,
acquire_timeout_secs,
"database connection established"
);
Ok(pool) Ok(pool)
} }
@@ -409,6 +428,9 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
-- ── Drop legacy actor columns ───────────────────────────────────────────── -- ── Drop legacy actor columns ─────────────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
ALTER TABLE audit_log 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) .execute(pool)
@@ -543,4 +565,75 @@ pub async fn snapshot_secret_history(
Ok(()) 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 ──────────────────────────────────────────────────────────────── // ── DB helpers ────────────────────────────────────────────────────────────────

View 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());
}
}

View File

@@ -2,5 +2,7 @@ pub mod audit;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod db; pub mod db;
pub mod error;
pub mod models; pub mod models;
pub mod service; pub mod service;
pub mod taxonomy;

View File

@@ -4,7 +4,7 @@ use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use uuid::Uuid; 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`. /// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry { pub struct Entry {
@@ -200,6 +200,8 @@ pub struct User {
pub key_params: Option<serde_json::Value>, pub key_params: Option<serde_json::Value>,
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login. /// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
pub api_key: Option<String>, 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 created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }

View File

@@ -7,6 +7,7 @@ use uuid::Uuid;
use crate::crypto; use crate::crypto;
use crate::db; use crate::db;
use crate::error::{AppError, DbErrorContext};
use crate::models::EntryRow; use crate::models::EntryRow;
// ── Key/value parsing helpers ───────────────────────────────────────────────── // ── Key/value parsing helpers ─────────────────────────────────────────────────
@@ -177,13 +178,18 @@ pub struct AddParams<'a> {
pub tags: &'a [String], pub tags: &'a [String],
pub meta_entries: &'a [String], pub meta_entries: &'a [String],
pub secret_entries: &'a [String], pub secret_entries: &'a [String],
pub secret_types: &'a std::collections::HashMap<String, String>,
pub link_secret_names: &'a [String], pub link_secret_names: &'a [String],
/// Optional user_id for multi-user isolation (None = single-user CLI mode) /// Optional user_id for multi-user isolation (None = single-user CLI mode)
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> { pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
let metadata = build_json(params.meta_entries)?; 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 secret_json = build_json(params.secret_entries)?;
let meta_keys = collect_key_paths(params.meta_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?;
let secret_keys = collect_key_paths(params.secret_entries)?; let secret_keys = collect_key_paths(params.secret_entries)?;
@@ -217,26 +223,40 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.await? .await?
}; };
if let Some(ref ex) = existing if let Some(ref ex) = existing {
&& let Err(e) = db::snapshot_entry_history( 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, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
entry_id: ex.id, entry_id: ex.id,
user_id: params.user_id, user_id: params.user_id,
folder: params.folder, folder: params.folder,
entry_type: params.entry_type, entry_type,
name: params.name, name: params.name,
version: ex.version, version: ex.version,
action: "add", action: "add",
tags: &ex.tags, tags: &ex.tags,
metadata: &ex.metadata, metadata: &history_metadata,
}, },
) )
.await .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 { let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar( sqlx::query_scalar(
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at) r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
@@ -254,7 +274,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
) )
.bind(uid) .bind(uid)
.bind(params.folder) .bind(params.folder)
.bind(params.entry_type) .bind(entry_type)
.bind(params.name) .bind(params.name)
.bind(params.notes) .bind(params.notes)
.bind(params.tags) .bind(params.tags)
@@ -277,7 +297,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
RETURNING id"#, RETURNING id"#,
) )
.bind(params.folder) .bind(params.folder)
.bind(params.entry_type) .bind(entry_type)
.bind(params.name) .bind(params.name)
.bind(params.notes) .bind(params.notes)
.bind(params.tags) .bind(params.tags)
@@ -292,26 +312,6 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .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: current_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
if existing.is_some() { if existing.is_some() {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ExistingField { struct ExistingField {
@@ -345,30 +345,42 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
} }
} }
let orphan_candidates: Vec<Uuid> = existing_fields.iter().map(|f| f.id).collect();
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1") sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
.bind(entry_id) .bind(entry_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query( if !orphan_candidates.is_empty() {
"DELETE FROM secrets s \ sqlx::query(
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", "DELETE FROM secrets s \
) WHERE s.id = ANY($1) \
.execute(&mut *tx) AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
.await?; )
.bind(&orphan_candidates)
.execute(&mut *tx)
.await?;
}
} }
for (field_name, field_value) in &flat_fields { for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?; 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( let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
) )
.bind(params.user_id) .bind(params.user_id)
.bind(field_name) .bind(field_name)
.bind(infer_secret_type(field_name)) .bind(secret_type)
.bind(&encrypted) .bind(&encrypted)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .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)") sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id) .bind(entry_id)
.bind(secret_id) .bind(secret_id)
@@ -409,12 +421,41 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
} }
} }
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( crate::audit::log_tx(
&mut tx, &mut tx,
params.user_id, params.user_id,
"add", "add",
params.folder, params.folder,
params.entry_type, entry_type,
params.name, params.name,
serde_json::json!({ serde_json::json!({
"tags": params.tags, "tags": params.tags,
@@ -429,32 +470,13 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
Ok(AddResult { Ok(AddResult {
name: params.name.to_string(), name: params.name.to_string(),
folder: params.folder.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(), tags: params.tags.to_vec(),
meta_keys, meta_keys,
secret_keys, secret_keys,
}) })
} }
pub(crate) fn infer_secret_type(name: &str) -> &'static str {
match name {
"ssh_key" => "pem",
"password" => "password",
"phone" | "phone_2" => "phone",
"webhook_url" | "address" => "url",
"access_key_id"
| "access_key_secret"
| "global_api_key"
| "api_key"
| "secret_key"
| "personal_access_token"
| "runner_token"
| "GOOGLE_CLIENT_ID"
| "GOOGLE_CLIENT_SECRET" => "token",
_ => "text",
}
}
fn validate_link_secret_names( fn validate_link_secret_names(
link_secret_names: &[String], link_secret_names: &[String],
new_secret_names: &BTreeSet<String>, new_secret_names: &BTreeSet<String>,
@@ -601,6 +623,7 @@ mod tests {
tags: &[], tags: &[],
meta_entries: &[], meta_entries: &[],
secret_entries: &[], secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name), link_secret_names: std::slice::from_ref(&secret_name),
user_id: None, user_id: None,
}, },
@@ -647,6 +670,7 @@ mod tests {
tags: &[], tags: &[],
meta_entries: &[], meta_entries: &[],
secret_entries: &[], secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name), link_secret_names: std::slice::from_ref(&secret_name),
user_id: None, user_id: None,
}, },
@@ -697,6 +721,7 @@ mod tests {
tags: &[], tags: &[],
meta_entries: &[], meta_entries: &[],
secret_entries: &[], secret_entries: &[],
secret_types: &Default::default(),
link_secret_names: std::slice::from_ref(&secret_name), link_secret_names: std::slice::from_ref(&secret_name),
user_id: None, user_id: None,
}, },
@@ -709,4 +734,69 @@ mod tests {
cleanup_test_rows(&pool, &marker).await?; cleanup_test_rows(&pool, &marker).await?;
Ok(()) 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(())
}
} }

View File

@@ -2,6 +2,8 @@ use anyhow::Result;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::error::AppError;
const KEY_PREFIX: &str = "sk_"; const KEY_PREFIX: &str = "sk_";
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total. /// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
@@ -14,23 +16,32 @@ pub fn generate_api_key() -> String {
} }
/// Return the user's existing API key, or generate and store a new one if NULL. /// 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> { pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let existing: Option<(Option<String>,)> = let mut tx = pool.begin().await?;
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.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); return Ok(key);
} }
// Generate and store new key atomically
let new_key = generate_api_key(); let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2") sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key) .bind(&new_key)
.bind(user_id) .bind(user_id)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?;
Ok(new_key) Ok(new_key)
} }

View File

@@ -4,20 +4,36 @@ use uuid::Uuid;
use crate::models::AuditLogEntry; 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 limit = limit.clamp(1, 200);
let offset = offset.max(0);
let rows = sqlx::query_as( let rows = sqlx::query_as(
"SELECT id, user_id, action, folder, type, name, detail, created_at \ "SELECT id, user_id, action, folder, type, name, detail, created_at \
FROM audit_log \ FROM audit_log \
WHERE user_id = $1 \ WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \ ORDER BY created_at DESC, id DESC \
LIMIT $2", LIMIT $2 OFFSET $3",
) )
.bind(user_id) .bind(user_id)
.bind(limit) .bind(limit)
.bind(offset)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(rows) 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)
}

View File

@@ -17,7 +17,6 @@ pub struct DeletedEntry {
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
pub struct DeleteResult { pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>, pub deleted: Vec<DeletedEntry>,
pub migrated: Vec<String>,
pub dry_run: bool, pub dry_run: bool,
} }
@@ -32,173 +31,9 @@ pub struct DeleteParams<'a> {
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
#[derive(Debug, sqlx::FromRow)] /// Maximum number of entries that can be deleted in a single bulk operation.
struct KeyReferrer { /// Prevents accidental mass deletion when filters are too broad.
id: Uuid, pub const MAX_BULK_DELETE: usize = 1000;
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
fn ref_label(r: &KeyReferrer) -> String {
format!("{}/{} ({})", r.folder, r.name, r.entry_type)
}
fn ref_path(r: &KeyReferrer) -> String {
format!("{}/{}", r.folder, r.name)
}
async fn fetch_key_referrers_pool(
pool: &PgPool,
key_entry_id: Uuid,
key_folder: &str,
key_name: &str,
user_id: Option<Uuid>,
) -> Result<Vec<KeyReferrer>> {
let qualified = format!("{}/{}", key_folder, key_name);
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id = $1 AND id <> $2 \
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
ORDER BY folder, type, name",
)
.bind(uid)
.bind(key_entry_id)
.bind(key_name)
.bind(&qualified)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id IS NULL AND id <> $1 \
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
ORDER BY folder, type, name",
)
.bind(key_entry_id)
.bind(key_name)
.bind(&qualified)
.fetch_all(pool)
.await?
};
Ok(refs)
}
async fn migrate_key_refs_if_needed(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key_row: &EntryRow,
key_name: &str,
user_id: Option<Uuid>,
dry_run: bool,
) -> Result<Vec<String>> {
let qualified = format!("{}/{}", key_row.folder, key_name);
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id = $1 AND id <> $2 \
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
ORDER BY folder, type, name",
)
.bind(uid)
.bind(key_row.id)
.bind(key_name)
.bind(&qualified)
.fetch_all(&mut **tx)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id IS NULL AND id <> $1 \
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
ORDER BY folder, type, name",
)
.bind(key_row.id)
.bind(key_name)
.bind(&qualified)
.fetch_all(&mut **tx)
.await?
};
if refs.is_empty() {
return Ok(vec![]);
}
if dry_run {
return Ok(refs.iter().map(ref_label).collect());
}
let owner = &refs[0];
let owner_path = ref_path(owner);
let key_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(key_row.id)
.fetch_all(&mut **tx)
.await?;
for f in &key_fields {
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(owner.id)
.bind(f.id)
.execute(&mut **tx)
.await?;
}
sqlx::query(
"UPDATE entries SET metadata = metadata - 'key_ref', \
version = version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(owner.id)
.execute(&mut **tx)
.await?;
crate::audit::log_tx(
tx,
user_id,
"key_migrate",
&owner.folder,
&owner.entry_type,
&owner.name,
json!({
"from_key": format!("{}/{}", key_row.folder, key_name),
"role": "new_owner",
"redirect_target": owner_path,
}),
)
.await;
for r in refs.iter().skip(1) {
sqlx::query(
"UPDATE entries SET metadata = jsonb_set(metadata, '{key_ref}', to_jsonb($2::text), true), \
version = version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(r.id)
.bind(&owner_path)
.execute(&mut **tx)
.await?;
crate::audit::log_tx(
tx,
user_id,
"key_migrate",
&r.folder,
&r.entry_type,
&r.name,
json!({
"from_key": format!("{}/{}", key_row.folder, key_name),
"role": "redirected_ref",
"redirect_to": owner_path,
}),
)
.await;
}
Ok(refs.iter().map(ref_label).collect())
}
/// Delete a single entry by id (multi-tenant: `user_id` must match). /// 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> { pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
@@ -224,8 +59,6 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
let entry_type = row.entry_type.clone(); let entry_type = row.entry_type.clone();
let name = row.name.clone(); let name = row.name.clone();
let entry_row: EntryRow = (&row).into(); let entry_row: EntryRow = (&row).into();
let migrated =
migrate_key_refs_if_needed(&mut tx, &entry_row, &name, Some(user_id), false).await?;
snapshot_and_delete( snapshot_and_delete(
&mut tx, &mut tx,
@@ -254,7 +87,6 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
folder, folder,
entry_type, entry_type,
}], }],
migrated,
dry_run: false, dry_run: false,
}) })
} }
@@ -294,6 +126,7 @@ async fn delete_one(
// - 2+ matches → disambiguation error (same as non-dry-run) // - 2+ matches → disambiguation error (same as non-dry-run)
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct DryRunRow { struct DryRunRow {
#[allow(dead_code)]
id: Uuid, id: Uuid,
folder: String, folder: String,
#[sqlx(rename = "type")] #[sqlx(rename = "type")]
@@ -339,20 +172,16 @@ async fn delete_one(
return match rows.len() { return match rows.len() {
0 => Ok(DeleteResult { 0 => Ok(DeleteResult {
deleted: vec![], deleted: vec![],
migrated: vec![],
dry_run: true, dry_run: true,
}), }),
1 => { 1 => {
let row = rows.into_iter().next().unwrap(); let row = rows.into_iter().next().unwrap();
let refs =
fetch_key_referrers_pool(pool, row.id, &row.folder, name, user_id).await?;
Ok(DeleteResult { Ok(DeleteResult {
deleted: vec![DeletedEntry { deleted: vec![DeletedEntry {
name: name.to_string(), name: name.to_string(),
folder: row.folder, folder: row.folder,
entry_type: row.entry_type, entry_type: row.entry_type,
}], }],
migrated: refs.iter().map(ref_label).collect(),
dry_run: true, dry_run: true,
}) })
} }
@@ -417,7 +246,6 @@ async fn delete_one(
tx.rollback().await?; tx.rollback().await?;
return Ok(DeleteResult { return Ok(DeleteResult {
deleted: vec![], deleted: vec![],
migrated: vec![],
dry_run: false, dry_run: false,
}); });
} }
@@ -437,7 +265,6 @@ async fn delete_one(
let folder = row.folder.clone(); let folder = row.folder.clone();
let entry_type = row.entry_type.clone(); let entry_type = row.entry_type.clone();
let migrated = migrate_key_refs_if_needed(&mut tx, &row, name, user_id, false).await?;
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?; snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
@@ -457,7 +284,6 @@ async fn delete_one(
folder, folder,
entry_type, entry_type,
}], }],
migrated,
dry_run: false, dry_run: false,
}) })
} }
@@ -497,33 +323,29 @@ async fn delete_bulk(
} }
if entry_type.is_some() { if entry_type.is_some() {
conditions.push(format!("type = ${}", idx)); conditions.push(format!("type = ${}", idx));
idx += 1;
} }
let where_clause = format!("WHERE {}", conditions.join(" AND ")); let where_clause = format!("WHERE {}", conditions.join(" AND "));
let sql = format!( let _ = idx; // used only for placeholder numbering in conditions
"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?;
if dry_run { if dry_run {
let mut migrated: Vec<String> = Vec::new(); let sql = format!(
for row in &rows { "SELECT id, version, folder, type, name, metadata, tags, notes \
let refs = FROM entries {where_clause} ORDER BY type, name"
fetch_key_referrers_pool(pool, row.id, &row.folder, &row.name, user_id).await?; );
migrated.extend(refs.iter().map(ref_label)); 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 let deleted = rows
.iter() .iter()
.map(|r| DeletedEntry { .map(|r| DeletedEntry {
@@ -534,15 +356,41 @@ async fn delete_bulk(
.collect(); .collect();
return Ok(DeleteResult { return Ok(DeleteResult {
deleted, deleted,
migrated,
dry_run: true, dry_run: true,
}); });
} }
let mut tx = pool.begin().await?;
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} 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()); let mut deleted = Vec::with_capacity(rows.len());
let mut migrated: Vec<String> = Vec::new();
for row in &rows { for row in &rows {
let entry_row = EntryRow { let entry_row: EntryRow = EntryRow {
id: row.id, id: row.id,
version: row.version, version: row.version,
folder: row.folder.clone(), folder: row.folder.clone(),
@@ -551,9 +399,6 @@ async fn delete_bulk(
metadata: row.metadata.clone(), metadata: row.metadata.clone(),
notes: row.notes.clone(), notes: row.notes.clone(),
}; };
let mut tx = pool.begin().await?;
let m = migrate_key_refs_if_needed(&mut tx, &entry_row, &row.name, user_id, false).await?;
migrated.extend(m);
snapshot_and_delete( snapshot_and_delete(
&mut tx, &mut tx,
&row.folder, &row.folder,
@@ -573,7 +418,6 @@ async fn delete_bulk(
json!({"bulk": true}), json!({"bulk": true}),
) )
.await; .await;
tx.commit().await?;
deleted.push(DeletedEntry { deleted.push(DeletedEntry {
name: row.name.clone(), name: row.name.clone(),
folder: row.folder.clone(), folder: row.folder.clone(),
@@ -581,9 +425,10 @@ async fn delete_bulk(
}); });
} }
tx.commit().await?;
Ok(DeleteResult { Ok(DeleteResult {
deleted, deleted,
migrated,
dry_run: false, dry_run: false,
}) })
} }
@@ -596,6 +441,15 @@ async fn snapshot_and_delete(
row: &EntryRow, row: &EntryRow,
user_id: Option<Uuid>, user_id: Option<Uuid>,
) -> Result<()> { ) -> 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( if let Err(e) = db::snapshot_entry_history(
tx, tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -607,7 +461,7 @@ async fn snapshot_and_delete(
version: row.version, version: row.version,
action: "delete", action: "delete",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .await
@@ -646,12 +500,17 @@ async fn snapshot_and_delete(
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
sqlx::query( let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
"DELETE FROM secrets s \ if !secret_ids.is_empty() {
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", sqlx::query(
) "DELETE FROM secrets s \
.execute(&mut **tx) WHERE s.id = ANY($1) \
.await?; AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(&secret_ids)
.execute(&mut **tx)
.await?;
}
Ok(()) Ok(())
} }
@@ -659,280 +518,153 @@ async fn snapshot_and_delete(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json; use sqlx::PgPool;
async fn maybe_test_pool() -> Option<PgPool> { async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else { let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip delete migration tests: SECRETS_DATABASE_URL is not set"); eprintln!("skip delete tests: SECRETS_DATABASE_URL is not set");
return None; return None;
}; };
let Ok(pool) = PgPool::connect(&url).await else { let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip delete migration tests: cannot connect to database"); eprintln!("skip delete tests: cannot connect to database");
return None; return None;
}; };
if let Err(e) = crate::db::migrate(&pool).await { if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip delete migration tests: migrate failed: {e}"); eprintln!("skip delete tests: migrate failed: {e}");
return None; return None;
} }
Some(pool) Some(pool)
} }
async fn insert_entry( async fn cleanup_single_user_rows(pool: &PgPool, marker: &str) -> Result<()> {
pool: &PgPool,
id: Uuid,
user_id: Uuid,
folder: &str,
entry_type: &str,
name: &str,
metadata: serde_json::Value,
) -> Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO entries (id, user_id, folder, type, name, notes, tags, metadata, version) \ "DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
VALUES ($1, $2, $3, $4, $5, '', ARRAY[]::text[], $6, 1)",
) )
.bind(id) .bind(format!("%{marker}%"))
.bind(user_id) .execute(pool)
.bind(folder) .await?;
.bind(entry_type) sqlx::query(
.bind(name) "DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
.bind(metadata) AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
)
.bind(format!("%{marker}%"))
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())
} }
async fn insert_secret_for_entry(
pool: &PgPool,
user_id: Uuid,
entry_id: Uuid,
name: &str,
secret_type: &str,
encrypted: Vec<u8>,
) -> Result<()> {
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(user_id)
.bind(name)
.bind(secret_type)
.bind(encrypted)
.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?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn delete_shared_key_dry_run_reports_migration_without_writes() -> Result<()> { async fn delete_dry_run_reports_matching_entry_without_writes() -> Result<()> {
let Some(pool) = maybe_test_pool().await else { let Some(pool) = maybe_test_pool().await else {
return Ok(()); return Ok(());
}; };
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("delete_dry_{}", &suffix[..8]);
let entry_name = format!("{}_entry", marker);
let user_id = Uuid::from_u128(rand::random()); cleanup_single_user_rows(&pool, &marker).await?;
let key_id = Uuid::from_u128(rand::random());
let ref_a = Uuid::from_u128(rand::random());
let ref_b = Uuid::from_u128(rand::random());
insert_entry( sqlx::query(
&pool, "INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata) \
key_id, VALUES (NULL, $1, 'service', $2, '', '{}', '{}')",
user_id,
"kfolder",
"key",
"shared-key",
json!({}),
)
.await?;
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![1_u8, 2, 3]).await?;
insert_entry(
&pool,
ref_a,
user_id,
"afolder",
"server",
"srv-a",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_b,
user_id,
"bfolder",
"server",
"srv-b",
json!({"key_ref":"shared-key"}),
) )
.bind(&marker)
.bind(&entry_name)
.execute(&pool)
.await?; .await?;
let result = run( let result = run(
&pool, &pool,
DeleteParams { DeleteParams {
name: Some("shared-key"), name: Some(&entry_name),
folder: Some("kfolder"), folder: Some(&marker),
entry_type: None, entry_type: None,
dry_run: true, dry_run: true,
user_id: Some(user_id), user_id: None,
}, },
) )
.await?; .await?;
assert!(result.dry_run); assert!(result.dry_run);
assert_eq!(result.deleted.len(), 1); assert_eq!(result.deleted.len(), 1);
assert_eq!(result.migrated.len(), 2); assert_eq!(result.deleted[0].name, entry_name);
let key_exists: bool = sqlx::query_scalar( let still_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)", "SELECT EXISTS(SELECT 1 FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2)",
) )
.bind(key_id) .bind(&marker)
.bind(user_id) .bind(&entry_name)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?;
assert!(key_exists); assert!(still_exists);
let ref_a_key_ref: Option<String> = cleanup_single_user_rows(&pool, &marker).await?;
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_a)
.fetch_one(&pool)
.await?;
let ref_b_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_b)
.fetch_one(&pool)
.await?;
assert_eq!(ref_a_key_ref.as_deref(), Some("kfolder/shared-key"));
assert_eq!(ref_b_key_ref.as_deref(), Some("shared-key"));
sqlx::query("DELETE FROM entries WHERE user_id = $1")
.bind(user_id)
.execute(&pool)
.await?;
Ok(()) Ok(())
} }
#[tokio::test] #[tokio::test]
async fn delete_shared_key_auto_migrates_single_copy_and_redirects_refs() -> Result<()> { async fn delete_by_id_removes_entry_and_orphan_secret() -> Result<()> {
let Some(pool) = maybe_test_pool().await else { let Some(pool) = maybe_test_pool().await else {
return Ok(()); 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 user_id = Uuid::from_u128(rand::random());
let key_id = Uuid::from_u128(rand::random()); let entry_name = format!("{}_entry", marker);
let ref_a = Uuid::from_u128(rand::random()); let secret_name = format!("{}_secret", marker);
let ref_b = Uuid::from_u128(rand::random());
let ref_c = Uuid::from_u128(rand::random());
insert_entry( sqlx::query("DELETE FROM entries WHERE user_id = $1 AND folder = $2")
&pool,
key_id,
user_id,
"kfolder",
"key",
"shared-key",
json!({}),
)
.await?;
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![7_u8, 8, 9]).await?;
// owner candidate (sorted first by folder)
insert_entry(
&pool,
ref_a,
user_id,
"afolder",
"server",
"srv-a",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_b,
user_id,
"bfolder",
"server",
"srv-b",
json!({"key_ref":"shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_c,
user_id,
"cfolder",
"service",
"svc-c",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
let result = run(
&pool,
DeleteParams {
name: Some("shared-key"),
folder: Some("kfolder"),
entry_type: None,
dry_run: false,
user_id: Some(user_id),
},
)
.await?;
assert!(!result.dry_run);
assert_eq!(result.deleted.len(), 1);
assert_eq!(result.migrated.len(), 3);
let key_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)",
)
.bind(key_id)
.bind(user_id)
.fetch_one(&pool)
.await?;
assert!(!key_exists);
let owner_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_a)
.fetch_one(&pool)
.await?;
let ref_b_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_b)
.fetch_one(&pool)
.await?;
let ref_c_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_c)
.fetch_one(&pool)
.await?;
assert_eq!(owner_key_ref, None);
assert_eq!(ref_b_key_ref.as_deref(), Some("afolder/srv-a"));
assert_eq!(ref_c_key_ref.as_deref(), Some("afolder/srv-a"));
let owner_has_copied: bool = sqlx::query_scalar(
"SELECT EXISTS( \
SELECT 1 \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = 'pem' \
)",
)
.bind(ref_a)
.fetch_one(&pool)
.await?;
assert!(owner_has_copied);
sqlx::query("DELETE FROM entries WHERE user_id = $1")
.bind(user_id) .bind(user_id)
.bind(&marker)
.execute(&pool) .execute(&pool)
.await?; .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(()) Ok(())
} }
} }

View File

@@ -40,7 +40,7 @@ async fn build_entry_env_map(
only_fields: &[String], only_fields: &[String],
prefix: &str, prefix: &str,
master_key: &[u8; 32], master_key: &[u8; 32],
user_id: Option<Uuid>, _user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
let entry_ids = vec![entry.id]; let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?; let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
@@ -68,44 +68,6 @@ async fn build_entry_env_map(
map.insert(key, json_to_env_string(&decrypted)); 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, None, 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.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) Ok(map)
} }

View File

@@ -31,8 +31,11 @@ pub async fn run(
let entry = resolve_entry(pool, name, folder, user_id).await?; let entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as( let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \ "SELECT DISTINCT ON (version) version, action, created_at \
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2", FROM entries_history \
WHERE entry_id = $1 \
ORDER BY version DESC, id DESC \
LIMIT $2",
) )
.bind(entry.id) .bind(entry.id)
.bind(limit as i64) .bind(limit as i64)

View File

@@ -54,7 +54,13 @@ pub async fn run(
.bind(params.user_id) .bind(params.user_id)
.fetch_one(pool) .fetch_one(pool)
.await .await
.unwrap_or(false); .map_err(|e| {
anyhow::anyhow!(
"Failed to check entry existence for '{}': {}",
entry.name,
e
)
})?;
if exists && !params.force { if exists && !params.force {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@@ -85,6 +91,7 @@ pub async fn run(
tags: &entry.tags, tags: &entry.tags,
meta_entries: &meta_entries, meta_entries: &meta_entries,
secret_entries: &secret_entries, secret_entries: &secret_entries,
secret_types: &Default::default(),
link_secret_names: &[], link_secret_names: &[],
user_id: params.user_id, user_id: params.user_id,
}, },

View File

@@ -1,3 +1,5 @@
use std::collections::HashSet;
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
@@ -122,7 +124,7 @@ pub async fn run(
sqlx::query_as( sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \ "SELECT folder, type, version, action, tags, metadata \
FROM entries_history \ 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(entry_id)
.bind(ver) .bind(ver)
@@ -149,6 +151,9 @@ pub async fn run(
) )
})?; })?;
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
let _ = master_key; let _ = master_key;
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
@@ -176,6 +181,15 @@ pub async fn run(
.await?; .await?;
let live_entry_id = if let Some(ref lr) = live { let live_entry_id = if let Some(ref lr) = live {
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()
}
};
if let Err(e) = db::snapshot_entry_history( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -187,7 +201,7 @@ pub async fn run(
version: lr.version, version: lr.version,
action: "rollback", action: "rollback",
tags: &lr.tags, tags: &lr.tags,
metadata: &lr.metadata, metadata: &history_metadata,
}, },
) )
.await .await
@@ -228,11 +242,13 @@ pub async fn run(
} }
sqlx::query( sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \ "UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \
updated_at = NOW() WHERE id = $3", updated_at = NOW() WHERE id = $5",
) )
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(lr.id) .bind(lr.id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -250,7 +266,7 @@ pub async fn run(
.bind(&snap.entry_type) .bind(&snap.entry_type)
.bind(name) .bind(name)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(snap.version) .bind(snap.version)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
@@ -264,16 +280,16 @@ pub async fn run(
.bind(&snap.entry_type) .bind(&snap.entry_type)
.bind(name) .bind(name)
.bind(&snap.tags) .bind(&snap.tags)
.bind(&snap.metadata) .bind(&snap_metadata)
.bind(snap.version) .bind(snap.version)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
} }
}; };
// In N:N mode, rollback restores entry metadata/tags only. if let Some(secret_snapshot) = snap_secret_snapshot {
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here. restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?;
let _ = live_entry_id; }
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
@@ -298,3 +314,144 @@ pub async fn run(
restored_version: snap.version, 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(())
}

View File

@@ -8,10 +8,23 @@ use crate::models::{Entry, SecretField};
pub const FETCH_ALL_LIMIT: u32 = 100_000; pub const FETCH_ALL_LIMIT: u32 = 100_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 struct SearchParams<'a> {
pub folder: Option<&'a str>, pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>, pub entry_type: Option<&'a str>,
pub name: 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 tags: &'a [String],
pub query: Option<&'a str>, pub query: Option<&'a str>,
pub sort: &'a str, pub sort: &'a str,
@@ -51,11 +64,15 @@ pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
if let Some(v) = a.name { if let Some(v) = a.name {
q = q.bind(v); 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 { for tag in a.tags {
q = q.bind(tag); q = q.bind(tag);
} }
if let Some(v) = a.query { if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_")); let pattern = ilike_pattern(v);
q = q.bind(pattern); q = q.bind(pattern);
} }
let n = q.fetch_one(pool).await?; let n = q.fetch_one(pool).await?;
@@ -86,6 +103,10 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
conditions.push(format!("name = ${}", idx)); conditions.push(format!("name = ${}", idx));
idx += 1; idx += 1;
} }
if a.name_query.is_some() {
conditions.push(format!("name ILIKE ${} ESCAPE '\\'", idx));
idx += 1;
}
if !a.tags.is_empty() { if !a.tags.is_empty() {
let placeholders: Vec<String> = a let placeholders: Vec<String> = a
.tags .tags
@@ -135,6 +156,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. /// 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( pub async fn fetch_entries(
pool: &PgPool, pool: &PgPool,
folder: Option<&str>, folder: Option<&str>,
@@ -148,6 +170,7 @@ pub async fn fetch_entries(
folder, folder,
entry_type, entry_type,
name, name,
name_query: None,
tags, tags,
query, query,
sort: "name", sort: "name",
@@ -189,11 +212,15 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
if let Some(v) = a.name { if let Some(v) = a.name {
q = q.bind(v); 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 { for tag in a.tags {
q = q.bind(tag); q = q.bind(tag);
} }
if let Some(v) = a.query { if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_")); let pattern = ilike_pattern(v);
q = q.bind(pattern); q = q.bind(pattern);
} }
q = q.bind(a.limit as i64).bind(a.offset as i64); q = q.bind(a.limit as i64).bind(a.offset as i64);
@@ -384,3 +411,13 @@ impl EntrySecretRow {
} }
} }
} }
#[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\%%");
}
}

View File

@@ -5,10 +5,11 @@ use uuid::Uuid;
use crate::crypto; use crate::crypto;
use crate::db; use crate::db;
use crate::error::{AppError, DbErrorContext};
use crate::models::{EntryRow, EntryWriteRow}; use crate::models::{EntryRow, EntryWriteRow};
use crate::service::add::{ use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, infer_secret_type, insert_path, collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_key_path, parse_kv, remove_path, parse_kv, remove_path,
}; };
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -23,6 +24,8 @@ pub struct UpdateResult {
pub remove_meta: Vec<String>, pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>, pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>, pub remove_secrets: Vec<String>,
pub linked_secrets: Vec<String>,
pub unlinked_secrets: Vec<String>,
} }
pub struct UpdateParams<'a> { pub struct UpdateParams<'a> {
@@ -35,7 +38,10 @@ pub struct UpdateParams<'a> {
pub meta_entries: &'a [String], pub meta_entries: &'a [String],
pub remove_meta: &'a [String], pub remove_meta: &'a [String],
pub secret_entries: &'a [String], pub secret_entries: &'a [String],
pub secret_types: &'a std::collections::HashMap<String, String>,
pub remove_secrets: &'a [String], pub remove_secrets: &'a [String],
pub link_secret_names: &'a [String],
pub unlink_secret_names: &'a [String],
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
@@ -90,10 +96,7 @@ pub async fn run(
let row = match rows.len() { let row = match rows.len() {
0 => { 0 => {
tx.rollback().await?; tx.rollback().await?;
anyhow::bail!( return Err(AppError::NotFoundEntry.into());
"Not found: '{}'. Use `add` to create it first.",
params.name
)
} }
1 => rows.into_iter().next().unwrap(), 1 => rows.into_iter().next().unwrap(),
_ => { _ => {
@@ -109,6 +112,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( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -120,7 +132,7 @@ pub async fn run(
version: row.version, version: row.version,
action: "update", action: "update",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .await
@@ -167,10 +179,7 @@ pub async fn run(
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
tx.rollback().await?; tx.rollback().await?;
anyhow::bail!( return Err(AppError::ConcurrentModification.into());
"Concurrent modification detected for '{}'. Please retry.",
params.name
);
} }
for entry in params.secret_entries { for entry in params.secret_entries {
@@ -224,15 +233,21 @@ pub async fn run(
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
} else { } 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( let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
) )
.bind(params.user_id) .bind(params.user_id)
.bind(field_name) .bind(field_name.to_string())
.bind(infer_secret_type(field_name)) .bind(secret_type)
.bind(&encrypted) .bind(&encrypted)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .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)") sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(row.id) .bind(row.id)
.bind(secret_id) .bind(secret_id)
@@ -292,6 +307,101 @@ pub async fn run(
} }
} }
// 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());
}
}
let meta_keys = collect_key_paths(params.meta_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?; let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?; let secret_keys = collect_key_paths(params.secret_entries)?;
@@ -301,8 +411,8 @@ pub async fn run(
&mut tx, &mut tx,
params.user_id, params.user_id,
"update", "update",
"", &row.folder,
"", &row.entry_type,
params.name, params.name,
serde_json::json!({ serde_json::json!({
"add_tags": params.add_tags, "add_tags": params.add_tags,
@@ -311,6 +421,8 @@ pub async fn run(
"remove_meta": remove_meta_keys, "remove_meta": remove_meta_keys,
"secret_keys": secret_keys, "secret_keys": secret_keys,
"remove_secrets": remove_secret_keys, "remove_secrets": remove_secret_keys,
"linked_secrets": linked_secrets,
"unlinked_secrets": unlinked_secrets,
}), }),
) )
.await; .await;
@@ -327,6 +439,8 @@ pub async fn run(
remove_meta: remove_meta_keys, remove_meta: remove_meta_keys,
secret_keys, secret_keys,
remove_secrets: remove_secret_keys, remove_secrets: remove_secret_keys,
linked_secrets,
unlinked_secrets,
}) })
} }
@@ -347,13 +461,13 @@ pub async fn update_fields_by_id(
user_id: Uuid, user_id: Uuid,
params: UpdateEntryFieldsByIdParams<'_>, params: UpdateEntryFieldsByIdParams<'_>,
) -> Result<()> { ) -> Result<()> {
if params.folder.len() > 128 { if params.folder.chars().count() > 128 {
anyhow::bail!("folder must be at most 128 characters"); 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"); 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"); anyhow::bail!("name must be at most 256 characters");
} }
@@ -372,10 +486,19 @@ pub async fn update_fields_by_id(
Some(r) => r, Some(r) => r,
None => { None => {
tx.rollback().await?; 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( if let Err(e) = db::snapshot_entry_history(
&mut tx, &mut tx,
db::EntrySnapshotParams { db::EntrySnapshotParams {
@@ -387,7 +510,7 @@ pub async fn update_fields_by_id(
version: row.version, version: row.version,
action: "update", action: "update",
tags: &row.tags, tags: &row.tags,
metadata: &row.metadata, metadata: &history_metadata,
}, },
) )
.await .await
@@ -395,13 +518,15 @@ pub async fn update_fields_by_id(
tracing::warn!(error = %e, "failed to snapshot entry history before web update"); tracing::warn!(error = %e, "failed to snapshot entry history before web update");
} }
let entry_type = params.entry_type.trim();
let res = sqlx::query( let res = sqlx::query(
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() \ version = version + 1, updated_at = NOW() \
WHERE id = $7 AND version = $8", WHERE id = $7 AND version = $8",
) )
.bind(params.folder) .bind(params.folder)
.bind(params.entry_type) .bind(entry_type)
.bind(params.name) .bind(params.name)
.bind(params.notes) .bind(params.notes)
.bind(params.tags) .bind(params.tags)
@@ -414,16 +539,17 @@ pub async fn update_fields_by_id(
if let sqlx::Error::Database(ref d) = e if let sqlx::Error::Database(ref d) = e
&& d.code().as_deref() == Some("23505") && d.code().as_deref() == Some("23505")
{ {
return anyhow::anyhow!( return AppError::ConflictEntryName {
"An entry with this folder and name already exists for your account." folder: params.folder.to_string(),
); name: params.name.to_string(),
};
} }
e.into() AppError::Internal(e.into())
})?; })?;
if res.rows_affected() == 0 { if res.rows_affected() == 0 {
tx.rollback().await?; tx.rollback().await?;
anyhow::bail!("Concurrent modification detected. Please refresh and try again."); return Err(AppError::ConcurrentModification.into());
} }
crate::audit::log_tx( crate::audit::log_tx(
@@ -431,7 +557,7 @@ pub async fn update_fields_by_id(
Some(user_id), Some(user_id),
"update", "update",
params.folder, params.folder,
params.entry_type, entry_type,
params.name, params.name,
serde_json::json!({ serde_json::json!({
"source": "web", "source": "web",

View File

@@ -16,24 +16,28 @@ pub struct OAuthProfile {
/// Find or create a user from an OAuth profile. /// Find or create a user from an OAuth profile.
/// Returns (user, is_new) where is_new indicates first-time registration. /// 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)> { 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( let existing: Option<OauthAccount> = sqlx::query_as(
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \ "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)
.bind(&profile.provider_id) .bind(&profile.provider_id)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if let Some(oa) = existing { if let Some(oa) = existing {
let user: User = sqlx::query_as( 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", FROM users WHERE id = $1",
) )
.bind(oa.user_id) .bind(oa.user_id)
.fetch_one(pool) .fetch_one(&mut *tx)
.await?; .await?;
tx.commit().await?;
return Ok((user, false)); return Ok((user, false));
} }
@@ -43,12 +47,10 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
.clone() .clone()
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string())); .unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
let mut tx = pool.begin().await?;
let user: User = sqlx::query_as( let user: User = sqlx::query_as(
"INSERT INTO users (email, name, avatar_url) \ "INSERT INTO users (email, name, avatar_url) \
VALUES ($1, $2, $3) \ 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(&profile.email)
.bind(&display_name) .bind(&display_name)
@@ -74,6 +76,53 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
Ok((user, true)) 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. /// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
pub async fn update_user_key_setup( pub async fn update_user_key_setup(
pool: &PgPool, pool: &PgPool,
@@ -98,7 +147,7 @@ pub async fn update_user_key_setup(
/// Fetch a user by ID. /// Fetch a user by ID.
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> { pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
let user = sqlx::query_as( 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", FROM users WHERE id = $1",
) )
.bind(user_id) .bind(user_id)
@@ -125,13 +174,16 @@ pub async fn bind_oauth_account(
user_id: Uuid, user_id: Uuid,
profile: OAuthProfile, profile: OAuthProfile,
) -> Result<OauthAccount> { ) -> 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( 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)
.bind(&profile.provider_id) .bind(&profile.provider_id)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if let Some((existing_user_id,)) = conflict { 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( 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(user_id)
.bind(&profile.provider) .bind(&profile.provider)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if existing_provider_for_user.is_some() { if existing_provider_for_user.is_some() {
@@ -174,9 +226,10 @@ pub async fn bind_oauth_account(
.bind(&profile.email) .bind(&profile.email)
.bind(&profile.name) .bind(&profile.name)
.bind(&profile.avatar_url) .bind(&profile.avatar_url)
.fetch_one(pool) .fetch_one(&mut *tx)
.await?; .await?;
tx.commit().await?;
Ok(account) 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") let mut tx = pool.begin().await?;
.bind(user_id)
.fetch_one(pool) let locked_accounts: Vec<(String,)> =
.await?; 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 { if count <= 1 {
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first."); 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") sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
.bind(user_id) .bind(user_id)
.bind(provider) .bind(provider)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?;
Ok(()) 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(())
}
}

View 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",
];

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.3.8" version = "0.5.9"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
@@ -17,9 +17,10 @@ rmcp = { version = "1", features = ["server", "macros", "transport-streamable-ht
axum = "0.8" axum = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] } axum-extra = { version = "0.10", features = ["typed-header"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
tower-sessions = "0.14" tower-sessions = "0.14"
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] } tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
governor = { version = "0.10", features = ["std", "jitter"] }
time = "0.3" time = "0.3"
# OAuth (manual token exchange via reqwest) # OAuth (manual token exchange via reqwest)
@@ -44,3 +45,4 @@ dotenvy.workspace = true
urlencoding = "2" urlencoding = "2"
schemars = "1" schemars = "1"
http = "1" http = "1"
url = "2"

View File

@@ -1,7 +1,5 @@
use std::net::SocketAddr;
use axum::{ use axum::{
extract::{ConnectInfo, Request, State}, extract::{Request, State},
http::StatusCode, http::StatusCode,
middleware::Next, middleware::Next,
response::Response, response::Response,
@@ -11,29 +9,14 @@ use uuid::Uuid;
use secrets_core::service::api_key::validate_api_key; use secrets_core::service::api_key::validate_api_key;
use crate::client_ip;
/// Injected into request extensions after Bearer token validation. /// Injected into request extensions after Bearer token validation.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AuthUser { pub struct AuthUser {
pub user_id: Uuid, 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. /// Axum middleware that validates Bearer API keys for the /mcp route.
/// Passes all non-MCP paths through without authentication. /// Passes all non-MCP paths through without authentication.
pub async fn bearer_auth_middleware( pub async fn bearer_auth_middleware(
@@ -43,7 +26,7 @@ pub async fn bearer_auth_middleware(
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let path = req.uri().path(); let path = req.uri().path();
let method = req.method().as_str(); 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 // Only authenticate /mcp paths
if !path.starts_with("/mcp") { if !path.starts_with("/mcp") {
@@ -66,7 +49,7 @@ pub async fn bearer_auth_middleware(
tracing::warn!( tracing::warn!(
method, method,
path, path,
client_ip = client_ip.as_deref(), %client_ip,
"invalid Authorization header format on /mcp (expected Bearer …)" "invalid Authorization header format on /mcp (expected Bearer …)"
); );
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
@@ -75,7 +58,7 @@ pub async fn bearer_auth_middleware(
tracing::warn!( tracing::warn!(
method, method,
path, path,
client_ip = client_ip.as_deref(), %client_ip,
"missing Authorization header on /mcp" "missing Authorization header on /mcp"
); );
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
@@ -93,7 +76,7 @@ pub async fn bearer_auth_middleware(
tracing::warn!( tracing::warn!(
method, method,
path, path,
client_ip = client_ip.as_deref(), %client_ip,
key_prefix = %&raw_key.chars().take(12).collect::<String>(), key_prefix = %&raw_key.chars().take(12).collect::<String>(),
key_len = raw_key.len(), 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)" "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!( tracing::error!(
method, method,
path, path,
client_ip = client_ip.as_deref(), %client_ip,
error = %e, error = %e,
"api key validation error" "api key validation error"
); );

View File

@@ -0,0 +1,65 @@
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())
}
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())
}

View 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,
),
}
}

View File

@@ -1,25 +1,28 @@
use std::net::SocketAddr;
use std::time::Instant; use std::time::Instant;
use axum::{ use axum::{
body::{Body, Bytes, to_bytes}, body::{Body, Bytes, to_bytes},
extract::{ConnectInfo, Request}, extract::Request,
http::{ http::{
HeaderMap, Method, StatusCode, HeaderMap, Method, StatusCode,
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
}, },
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use crate::auth::AuthUser;
/// Axum middleware that logs structured info for every HTTP request. /// Axum middleware that logs structured info for every HTTP request.
/// ///
/// All requests: method, path, status, latency_ms, client_ip, user_agent. /// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method, /// 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 /// Sensitive headers (Authorization, X-Encryption-Key) are never logged in
/// are never logged. /// full — only short fingerprints are emitted.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response { pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone(); let method = req.method().clone();
let path = req.uri().path().to_string(); 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()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()); .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_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE) let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json")) .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); let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 { if cap <= 512 * 1024 {
let (parts, body) = req.into_parts(); 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 { match to_bytes(body, 512 * 1024).await {
Ok(bytes) => { Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&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(), ua.as_deref(),
content_len, content_len,
mcp_session.as_deref(), mcp_session.as_deref(),
auth_key.as_deref(),
&enc_key,
user_id.as_deref(),
&rpc, &rpc,
); );
return resp; return resp;
@@ -78,6 +93,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
ua = ua.as_deref(), ua = ua.as_deref(),
content_length = content_len, content_length = content_len,
mcp_session = mcp_session.as_deref(), 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", "mcp request",
); );
return ( return (
@@ -160,6 +178,9 @@ fn log_mcp_request(
ua: Option<&str>, ua: Option<&str>,
content_length: Option<u64>, content_length: Option<u64>,
mcp_session: Option<&str>, mcp_session: Option<&str>,
auth_key: Option<&str>,
enc_key: &str,
user_id: Option<&str>,
rpc: &JsonRpcMeta, rpc: &JsonRpcMeta,
) { ) {
tracing::info!( tracing::info!(
@@ -175,18 +196,94 @@ fn log_mcp_request(
tool = rpc.tool_name.as_deref(), tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(), jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size, batch_size = rpc.batch_size,
tool_args = rpc.tool_args.as_deref(),
auth_key,
enc_key,
user_id,
"mcp request", "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 ───────────────────────────────────────────────────── // ── 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)] #[derive(Debug, Default)]
struct JsonRpcMeta { struct JsonRpcMeta {
request_id: Option<String>, request_id: Option<String>,
rpc_method: Option<String>, rpc_method: Option<String>,
tool_name: Option<String>, tool_name: Option<String>,
batch_size: Option<usize>, batch_size: Option<usize>,
/// Non-sensitive tool call arguments for diagnostic logging.
tool_args: Option<String>,
} }
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta { fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
@@ -216,12 +313,47 @@ fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
.pointer("/params/name") .pointer("/params/name")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let tool_args = extract_tool_args(value);
JsonRpcMeta { JsonRpcMeta {
request_id, request_id,
rpc_method, rpc_method,
tool_name, tool_name,
batch_size: None, 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> { fn client_ip(req: &Request) -> Option<String> {
if let Some(first) = req crate::client_ip::extract_client_ip(req).into()
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
} }

View File

@@ -1,7 +1,11 @@
mod auth; mod auth;
mod client_ip;
mod error;
mod logging; mod logging;
mod oauth; mod oauth;
mod rate_limit;
mod tools; mod tools;
mod validation;
mod web; mod web;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -40,6 +44,14 @@ fn load_env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty()) 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> { 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_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?; let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
@@ -144,10 +156,20 @@ async fn main() -> Result<()> {
); );
// ── Router ──────────────────────────────────────────────────────────────── // ── Router ────────────────────────────────────────────────────────────────
let cors = CorsLayer::new() // CORS: restrict origins in production, allow all in development
.allow_origin(Any) let is_production = matches!(
.allow_methods(Any) load_env_var("SECRETS_ENV")
.allow_headers(Any); .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 router = Router::new() let router = Router::new()
.merge(web::web_router()) .merge(web::web_router())
@@ -159,8 +181,15 @@ async fn main() -> Result<()> {
pool, pool,
auth::bearer_auth_middleware, auth::bearer_auth_middleware,
)) ))
.layer(axum::middleware::from_fn_with_state(
rate_limit_state.clone(),
rate_limit::rate_limit_middleware,
))
.layer(session_layer) .layer(session_layer)
.layer(cors) .layer(cors)
.layer(tower_http::limit::RequestBodyLimitLayer::new(
10 * 1024 * 1024,
))
.with_state(app_state); .with_state(app_state);
// ── Start server ────────────────────────────────────────────────────────── // ── Start server ──────────────────────────────────────────────────────────
@@ -168,7 +197,10 @@ async fn main() -> Result<()> {
.await .await
.with_context(|| format!("failed to bind to {}", bind_addr))?; .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); tracing::info!("MCP endpoint: {}/mcp", base_url);
axum::serve( axum::serve(
@@ -180,12 +212,135 @@ async fn main() -> Result<()> {
.context("server error")?; .context("server error")?;
session_cleanup.abort(); session_cleanup.abort();
rate_limit_cleanup.abort();
Ok(()) Ok(())
} }
async fn shutdown_signal() { async fn shutdown_signal() {
tokio::signal::ctrl_c() let ctrl_c = tokio::signal::ctrl_c();
.await
.expect("failed to install CTRL+C signal handler"); #[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..."); 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;
}
}

View 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

View 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

View File

@@ -38,6 +38,10 @@
} }
.topbar-spacer { flex: 1; } .topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); } .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 { .btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border); padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer; background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
@@ -46,8 +50,25 @@
.main { padding: 32px 24px 40px; flex: 1; } .main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; } padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; } .card-title-row {
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; } display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-bottom: 20px;
}
.card-title { font-size: 20px; font-weight: 600; margin: 0; }
.card-title-count {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
line-height: 1;
font-family: 'JetBrains Mono', monospace;
}
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; } .empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); } th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
@@ -77,13 +98,28 @@
td::before { td::before {
display: block; color: var(--text-muted); font-size: 11px; display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase; 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; } .detail { max-width: none; }
} }
.pagination {
display: flex; align-items: center; gap: 8px; margin-top: 20px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { background: var(--surface2); }
.page-btn-disabled {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
}
</style> </style>
</head> </head>
<body> <body>
@@ -91,9 +127,9 @@
<aside class="sidebar"> <aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a> <a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu"> <nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a> <a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link">条目</a> <a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
<a href="/audit" class="sidebar-link active">审计</a> <a href="/audit" class="sidebar-link active" data-i18n="navAudit">审计</a>
</nav> </nav>
</aside> </aside>
@@ -101,46 +137,89 @@
<div class="topbar"> <div class="topbar">
<span class="topbar-spacer"></span> <span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</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"> <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> </form>
</div> </div>
<main class="main"> <main class="main">
<section class="card"> <section class="card">
<div class="card-title">我的审计</div> <div class="card-title-row">
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div> <div class="card-title" data-i18n="auditTitle">我的审计</div>
<span class="card-title-count">{{ total_count }}</span>
</div>
{% if entries.is_empty() %} {% if entries.is_empty() %}
<div class="empty">暂无审计记录。</div> <div class="empty" data-i18n="emptyAudit">暂无审计记录。</div>
{% else %} {% else %}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>时间</th> <th data-i18n="colTime">时间</th>
<th>动作</th> <th data-i18n="colAction">动作</th>
<th>目标</th> <th data-i18n="colTarget">目标</th>
<th>详情</th> <th data-i18n="colDetail">详情</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in entries %} {% for entry in entries %}
<tr> <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-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">{{ entry.action }}</td> <td class="col-action mono" data-label="动作">{{ entry.action }}</td>
<td class="col-target mono">{{ entry.target }}</td> <td class="col-target mono" data-label="目标">{{ entry.target }}</td>
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td> <td class="col-detail" data-label="详情"><pre class="detail">{{ entry.detail }}</pre></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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 %} {% endif %}
</section> </section>
</main> </main>
</div> </div>
</div> </div>
<script src="/static/i18n.js"></script>
<script> <script>
(function () { (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) { document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime'); var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null; var d = raw ? new Date(raw) : null;
@@ -149,6 +228,7 @@
el.title = raw + ' (UTC)'; el.title = raw + ' (UTC)';
} }
}); });
applyLang();
})(); })();
</script> </script>
</body> </body>

View File

@@ -305,6 +305,17 @@
<div class="modal-bd" id="change-modal"> <div class="modal-bd" id="change-modal">
<div class="modal"> <div class="modal">
<h3 data-i18n="changeTitle">更换密码</h3> <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"> <div class="field">
<label data-i18n="labelNew">新密码</label> <label data-i18n="labelNew">新密码</label>
<div class="pw-field"> <div class="pw-field">
@@ -345,8 +356,10 @@ const T = {
labelPassphrase: '加密密码', labelPassphrase: '加密密码',
labelConfirm: '确认密码', labelConfirm: '确认密码',
labelNew: '新密码', labelNew: '新密码',
labelCurrent: '当前密码',
phPassphrase: '输入密码…', phPassphrase: '输入密码…',
phConfirm: '再次输入…', phConfirm: '再次输入…',
phCurrent: '输入当前密码…',
btnSetup: '设置并获取配置', btnSetup: '设置并获取配置',
btnUnlock: '解锁并获取配置', btnUnlock: '解锁并获取配置',
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。', setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
@@ -354,6 +367,7 @@ const T = {
errShort: '密码至少需要 8 个字符。', errShort: '密码至少需要 8 个字符。',
errMismatch: '两次输入不一致。', errMismatch: '两次输入不一致。',
errWrong: '密码错误,请重试。', errWrong: '密码错误,请重试。',
errWrongOld: '当前密码错误,请重试。',
unlockedTitle: 'MCP 配置', unlockedTitle: 'MCP 配置',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI', tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -379,8 +393,10 @@ const T = {
labelPassphrase: '加密密碼', labelPassphrase: '加密密碼',
labelConfirm: '確認密碼', labelConfirm: '確認密碼',
labelNew: '新密碼', labelNew: '新密碼',
labelCurrent: '目前密碼',
phPassphrase: '輸入密碼…', phPassphrase: '輸入密碼…',
phConfirm: '再次輸入…', phConfirm: '再次輸入…',
phCurrent: '輸入目前密碼…',
btnSetup: '設定並取得設定', btnSetup: '設定並取得設定',
btnUnlock: '解鎖並取得設定', btnUnlock: '解鎖並取得設定',
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。', setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
@@ -388,6 +404,7 @@ const T = {
errShort: '密碼至少需要 8 個字元。', errShort: '密碼至少需要 8 個字元。',
errMismatch: '兩次輸入不一致。', errMismatch: '兩次輸入不一致。',
errWrong: '密碼錯誤,請重試。', errWrong: '密碼錯誤,請重試。',
errWrongOld: '目前密碼錯誤,請重試。',
unlockedTitle: 'MCP 設定', unlockedTitle: 'MCP 設定',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI', tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -413,8 +430,10 @@ const T = {
labelPassphrase: 'Encryption password', labelPassphrase: 'Encryption password',
labelConfirm: 'Confirm password', labelConfirm: 'Confirm password',
labelNew: 'New password', labelNew: 'New password',
labelCurrent: 'Current password',
phPassphrase: 'Enter password…', phPassphrase: 'Enter password…',
phConfirm: 'Repeat password…', phConfirm: 'Repeat password…',
phCurrent: 'Enter current password…',
btnSetup: 'Set up & get config', btnSetup: 'Set up & get config',
btnUnlock: 'Unlock & get config', btnUnlock: 'Unlock & get config',
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.', setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
@@ -422,6 +441,7 @@ const T = {
errShort: 'Password must be at least 8 characters.', errShort: 'Password must be at least 8 characters.',
errMismatch: 'Passwords do not match.', errMismatch: 'Passwords do not match.',
errWrong: 'Incorrect password, please try again.', errWrong: 'Incorrect password, please try again.',
errWrongOld: 'Current password is incorrect, please try again.',
unlockedTitle: 'MCP Config', unlockedTitle: 'MCP Config',
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI', tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -832,14 +852,16 @@ async function confirmRegenerate() {
// ── Change passphrase modal ──────────────────────────────────────────────────── // ── Change passphrase modal ────────────────────────────────────────────────────
function openChangeModal() { function openChangeModal() {
document.getElementById('change-pass-old').value = '';
document.getElementById('change-pass1').value = ''; document.getElementById('change-pass1').value = '';
document.getElementById('change-pass2').value = ''; document.getElementById('change-pass2').value = '';
document.getElementById('change-pass-old').type = 'password';
document.getElementById('change-pass1').type = 'password'; document.getElementById('change-pass1').type = 'password';
document.getElementById('change-pass2').type = 'password'; document.getElementById('change-pass2').type = 'password';
document.getElementById('change-error').style.display = 'none'; document.getElementById('change-error').style.display = 'none';
document.getElementById('change-modal').classList.add('open'); document.getElementById('change-modal').classList.add('open');
syncPwToggleI18n(); syncPwToggleI18n();
setTimeout(() => document.getElementById('change-pass1').focus(), 50); setTimeout(() => document.getElementById('change-pass-old').focus(), 50);
} }
function closeChangeModal() { function closeChangeModal() {
@@ -847,11 +869,13 @@ function closeChangeModal() {
} }
async function doChange() { async function doChange() {
const passOld = document.getElementById('change-pass-old').value;
const pass1 = document.getElementById('change-pass1').value; const pass1 = document.getElementById('change-pass1').value;
const pass2 = document.getElementById('change-pass2').value; const pass2 = document.getElementById('change-pass2').value;
const errEl = document.getElementById('change-error'); const errEl = document.getElementById('change-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
if (!passOld) { showErr(errEl, t('errEmpty')); return; }
if (!pass1) { showErr(errEl, t('errEmpty')); return; } if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; } if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; } if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
@@ -860,24 +884,39 @@ async function doChange() {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>'; btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
try { try {
const salt = crypto.getRandomValues(new Uint8Array(32)); // Fetch current salt to derive old key for verification
const cryptoKey = await deriveKey(pass1, salt, true); const saltResp = await fetchAuth('/api/key-salt');
const keyCheckHex = await encryptKeyCheck(cryptoKey); if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
const hexKey = await exportKeyHex(cryptoKey); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
salt: bytesToHex(salt), old_key: oldHexKey,
key_check: keyCheckHex, new_key: newHexKey,
salt: bytesToHex(newSalt),
key_check: newKeyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS } params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
}) })
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
currentEncKey = hexKey; currentEncKey = newHexKey;
sessionStorage.setItem('enc_key', hexKey); sessionStorage.setItem('enc_key', newHexKey);
renderRealConfig(); renderRealConfig();
closeChangeModal(); closeChangeModal();
} catch (e) { } catch (e) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
var I18N_SHARED = {
'zh-CN': {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: '条目',
navAudit: '审计',
signOut: '退出',
mobileLabelTime: '时间',
mobileLabelAction: '动作',
mobileLabelTarget: '目标',
mobileLabelDetail: '详情'
},
'zh-TW': {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: '條目',
navAudit: '審計',
signOut: '登出',
mobileLabelTime: '時間',
mobileLabelAction: '動作',
mobileLabelTarget: '目標',
mobileLabelDetail: '詳情'
},
en: {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: 'Entries',
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('.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();
};

View File

@@ -31,7 +31,23 @@ GOOGLE_CLIENT_SECRET=
# ─── 日志(可选)────────────────────────────────────────────────────── # ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_mcp=debug # RUST_LOG=secrets_mcp=debug
# ─── 注意 ───────────────────────────────────────────────────────────── # ─── 数据库连接池(可选)──────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。 # 最大连接数,默认 10
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。 # SECRETS_DATABASE_POOL_SIZE=10
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。 # 获取连接超时秒数,默认 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

View File

@@ -1,126 +0,0 @@
-- Entry-Secret N:N migration (manual SQL)
-- Safe to re-run: uses IF EXISTS/IF NOT EXISTS guards.
BEGIN;
-- 1) secrets: add new columns
ALTER TABLE secrets
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE secrets
ADD COLUMN IF NOT EXISTS type VARCHAR(64) NOT NULL DEFAULT 'text';
-- 2) rename field_name -> name (idempotent)
DO $$ BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'secrets' AND column_name = 'field_name'
) THEN
ALTER TABLE secrets RENAME COLUMN field_name TO name;
END IF;
END $$;
-- 3) create join table
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);
-- 4) backfill user_id and relationship from old secrets.entry_id
DO $$ BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'secrets' AND column_name = 'entry_id'
) THEN
UPDATE secrets s
SET user_id = e.user_id
FROM entries e
WHERE s.entry_id = e.id AND s.user_id IS NULL;
INSERT INTO entry_secrets(entry_id, secret_id, sort_order)
SELECT entry_id, id, 0
FROM secrets
WHERE entry_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
-- 5) backfill secret types
UPDATE secrets SET type = 'pem' WHERE name IN ('ssh_key');
UPDATE secrets SET type = 'password' WHERE name IN ('password');
UPDATE secrets SET type = 'phone' WHERE name LIKE 'phone%';
UPDATE secrets SET type = 'url' WHERE name IN ('webhook_url', 'address');
UPDATE secrets
SET type = 'token'
WHERE name IN (
'access_key_id',
'access_key_secret',
'global_api_key',
'api_key',
'secret_key',
'personal_access_token',
'runner_token',
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET'
);
-- 6) drop old entry_id path
ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_fkey;
DROP INDEX IF EXISTS idx_secrets_entry_id;
ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_field_name_key;
ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_name_key;
ALTER TABLE secrets DROP COLUMN IF EXISTS entry_id;
-- 7) add indexes for new access paths
CREATE INDEX IF NOT EXISTS idx_secrets_user_id
ON secrets(user_id) WHERE user_id IS NOT NULL;
DO $$
DECLARE
duplicate_samples TEXT;
BEGIN
SELECT string_agg(
format('user_id=%s, name=%s, count=%s', t.user_id, t.name, t.cnt),
E'\n'
)
INTO duplicate_samples
FROM (
SELECT user_id::TEXT AS user_id, name, COUNT(*) AS cnt
FROM secrets
WHERE user_id IS NOT NULL
GROUP BY user_id, name
HAVING COUNT(*) > 1
ORDER BY cnt DESC, user_id, name
LIMIT 20
) t;
IF duplicate_samples IS NOT NULL THEN
RAISE EXCEPTION
'Cannot enforce unique constraint on secrets(user_id, name). Duplicates found:%',
E'\n' || duplicate_samples
USING HINT = 'Please deduplicate conflicting rows, then rerun migration.';
END IF;
END $$;
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);
-- 8) secrets_history: rename and remove entry-scoped columns
DO $$ BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'secrets_history' AND column_name = 'field_name'
) THEN
ALTER TABLE secrets_history RENAME COLUMN field_name TO name;
END IF;
END $$;
ALTER TABLE secrets_history DROP COLUMN IF EXISTS entry_id;
ALTER TABLE secrets_history DROP COLUMN IF EXISTS entry_version;
COMMIT;

View File

@@ -1,67 +0,0 @@
-- Metadata cleanup migration (manual SQL)
-- Keep tags/type as dedicated columns; remove duplicated metadata keys.
BEGIN;
-- 1) Promote metadata.type -> entries.type when present.
UPDATE entries
SET type = metadata->>'type'
WHERE metadata->>'type' IS NOT NULL
AND metadata->>'type' <> '';
-- 2) Remove metadata.type.
UPDATE entries
SET metadata = metadata - 'type'
WHERE metadata ? 'type';
-- 3) Remove metadata.environment (duplicated by tags prod/dev).
UPDATE entries
SET metadata = metadata - 'environment'
WHERE metadata ? 'environment';
-- 4) Remove metadata.account when equal to folder.
UPDATE entries
SET metadata = metadata - 'account'
WHERE metadata->>'account' = folder;
-- 5) Normalize manufacturer -> provider.
UPDATE entries
SET metadata = (metadata - 'manufacturer')
|| jsonb_build_object('provider', metadata->>'manufacturer')
WHERE metadata ? 'manufacturer'
AND NOT metadata ? 'provider';
UPDATE entries
SET metadata = metadata - 'manufacturer'
WHERE metadata ? 'manufacturer'
AND metadata ? 'provider';
-- 6) Drop ssh_key_format (moved to secrets.type).
UPDATE entries
SET metadata = metadata - 'ssh_key_format'
WHERE metadata ? 'ssh_key_format';
-- 7) Remove display_name when duplicated by name.
UPDATE entries
SET metadata = metadata - 'display_name'
WHERE metadata->>'display_name' = name;
-- 8) Condense server_* metadata into server_ref.
UPDATE entries
SET metadata = metadata
- 'server_account'
- 'server_hostname'
- 'server_location'
- 'server_public_ip'
|| CASE
WHEN metadata ? 'server_entry_name'
THEN jsonb_build_object('server_ref', metadata->>'server_entry_name')
ELSE '{}'::jsonb
END
WHERE metadata ? 'server_entry_name' OR metadata ? 'server_account';
UPDATE entries
SET metadata = metadata - 'server_entry_name'
WHERE metadata ? 'server_entry_name';
COMMIT;

View File

@@ -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;

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Migrate PostgreSQL data from secrets-mcp-prod to secrets-nn-test.
#
# Prereqs: pg_dump and pg_restore (PostgreSQL client tools) on PATH.
# TLS: Use the same connection parameters as your MCP / app (e.g. sslmode=verify-full
# and PGSSLROOTCERT if needed). If local psql fails with "certificate verify failed",
# run this script from a host that trusts the server CA, or set PGSSLROOTCERT.
#
# Usage:
# export SOURCE_DATABASE_URL='postgres://USER:PASS@host:5432/secrets-mcp-prod?sslmode=verify-full'
# export TARGET_DATABASE_URL='postgres://USER:PASS@host:5432/secrets-nn-test?sslmode=verify-full'
# ./scripts/migrate-db-prod-to-nn-test.sh
#
# Options (env):
# BACKUP_TARGET_FIRST=1 # default: dump target to ./backup-secrets-nn-test-<timestamp>.dump before restore
# RUN_NN_SQL=1 # default: run migrations/001_nn_schema.sql then 002_data_cleanup.sql on target after restore
# SKIP_TARGET_BACKUP=1 # skip target backup
#
# WARNINGS:
# - pg_restore with --clean --if-exists drops objects that exist in the dump; target DB is replaced
# to match the logical content of the source dump (same as typical full restore).
# - Optionally keep a manual dump of the target before proceeding.
# - 001_nn_schema.sql will fail if secrets has duplicate (user_id, name) after backfill; fix data first.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
SOURCE_URL="${SOURCE_DATABASE_URL:-}"
TARGET_URL="${TARGET_DATABASE_URL:-}"
if [[ -z "$SOURCE_URL" || -z "$TARGET_URL" ]]; then
echo "Set SOURCE_DATABASE_URL and TARGET_DATABASE_URL (postgres URLs)." >&2
exit 1
fi
if ! command -v pg_dump >/dev/null || ! command -v pg_restore >/dev/null; then
echo "pg_dump and pg_restore are required." >&2
exit 1
fi
TS="$(date +%Y%m%d%H%M%S)"
DUMP_FILE="${DUMP_FILE:-$ROOT/tmp/secrets-mcp-prod-${TS}.dump}"
mkdir -p "$(dirname "$DUMP_FILE")"
if [[ "${EXCLUDE_TOWER_SESSIONS:-}" == "1" ]]; then
echo "==> Excluding schema tower_sessions from dump"
pg_dump "$SOURCE_URL" -Fc --no-owner --no-acl --exclude-schema=tower_sessions -f "$DUMP_FILE"
else
echo "==> Dumping source (custom format) -> $DUMP_FILE"
pg_dump "$SOURCE_URL" -Fc --no-owner --no-acl -f "$DUMP_FILE"
fi
if [[ "${SKIP_TARGET_BACKUP:-}" != "1" && "${BACKUP_TARGET_FIRST:-1}" == "1" ]]; then
BACKUP_FILE="$ROOT/tmp/secrets-nn-test-before-${TS}.dump"
echo "==> Backing up target -> $BACKUP_FILE"
pg_dump "$TARGET_URL" -Fc --no-owner --no-acl -f "$BACKUP_FILE" || {
echo "Target backup failed (empty DB is OK). Continuing." >&2
}
fi
echo "==> Restoring into target (--clean --if-exists)"
pg_restore -d "$TARGET_URL" --no-owner --no-acl --clean --if-exists --verbose "$DUMP_FILE"
if [[ "${RUN_NN_SQL:-1}" == "1" ]]; then
if [[ ! -f "$ROOT/migrations/001_nn_schema.sql" ]]; then
echo "migrations/001_nn_schema.sql not found; skip NN SQL." >&2
else
echo "==> Applying migrations/001_nn_schema.sql on target"
psql "$TARGET_URL" -v ON_ERROR_STOP=1 -f "$ROOT/migrations/001_nn_schema.sql"
fi
if [[ -f "$ROOT/migrations/002_data_cleanup.sql" ]]; then
echo "==> Applying migrations/002_data_cleanup.sql on target"
psql "$TARGET_URL" -v ON_ERROR_STOP=1 -f "$ROOT/migrations/002_data_cleanup.sql"
fi
fi
echo "==> Done. Suggested verification:"
echo " psql \"\$TARGET_DATABASE_URL\" -c \"SELECT COUNT(*) FROM entries; SELECT COUNT(*) FROM secrets; SELECT COUNT(*) FROM entry_secrets;\""
echo " ./scripts/release-check.sh # optional app-side sanity"

View File

@@ -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;

View File

@@ -11,7 +11,7 @@ tag="secrets-mcp-${version}"
echo "==> 当前 secrets-mcp 版本: ${version}" echo "==> 当前 secrets-mcp 版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}" echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then if jj log --no-graph --revisions "tag(${tag})" --limit 1 >/dev/null 2>&1; then
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。" echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。" echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
else else

View File

@@ -0,0 +1 @@
entry_id,secret_name,secret_value
1 entry_id secret_name secret_value

View 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())