Compare commits

..

11 Commits

Author SHA1 Message Date
王松
c6fb457734 feat(nn): entry–secret N:N, unique secret names, web unlink
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 2m37s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Bump secrets-mcp to 0.3.8 (tag 0.3.7 already used).

- Junction table entry_secrets; secrets user-scoped with type
- Per-user unique secrets.name; link_secret_names on add
- Manual migrations + migrate script; MCP/tool and Web updates

Made-with: Cursor
2026-04-03 17:37:04 +08:00
df701f21b9 feat(secrets-mcp): 共享 key 删除时自动迁移并重定向 (v0.3.7)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m4s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
删除仍被 metadata.key_ref 引用的 key 条目时,在同一事务内将密文复制到首个引用方,
其余引用方的 key_ref 重定向到新 owner;env_map 解析 key_ref 时不再限定 type=key。
Web 删除 API 返回 migrated;Dashboard 删除成功后提示迁移。

Bump secrets-mcp to 0.3.7;补充删除迁移相关单测(需 SECRETS_DATABASE_URL)。

Made-with: Cursor
2026-04-03 09:27:20 +08:00
c3c536200e feat(secrets-mcp): Web 条目编辑 API 与 Notes 列表展示优化(0.3.6)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: EntryWriteRow;按 id 更新/删除(含并发冲突与唯一键)
- Web: PATCH/DELETE /api/entries/{id};列表编辑/删除与错误映射
- entries 模板:Notes 限高滚动;空 Notes 不显示占位框
- 版本 0.3.5 → 0.3.6,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:58:10 +08:00
7909f7102d feat(secrets-mcp): 条目页按 folder/type 筛选并发版 0.3.5
- entries 路由支持 ?folder=&type= 查询,与搜索层 SearchParams 对齐
- 条目列表页增加筛选表单与说明文案
- 版本 0.3.4 → 0.3.5,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:37:36 +08:00
87a29af82d feat(web): 条目列表页 /entries 与总条数统计
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m56s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 新增受会话保护的 GET /entries,仅展示 entries 非敏感列
- search: list_entries、count_entries 共享筛选条件;分页与计数不读 secrets
- 侧边栏在 dashboard/audit 增加「条目」入口
- secrets-mcp 0.3.4(tag 尚未存在)

Made-with: Cursor
2026-04-02 11:26:51 +08:00
1b11f7e976 release(secrets-mcp): v0.3.3 — 强制 PostgreSQL TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 7s
显式引入数据库 TLS 配置并在生产环境拒绝弱 sslmode,避免连接静默降级。同步更新 deploy/README 与运维 runbook,落地 db.refining.ltd 的证书与服务器配置流程。

Made-with: Cursor
2026-04-01 15:18:14 +08:00
08e81363c9 release(secrets-mcp): v0.3.2 — 修复 key_ref 多租户与歧义
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m41s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- env_map:key_ref 解析传入 user_id;支持 folder/name;多条匹配时报错
- 文档同步 key_ref 说明
- bump secrets-mcp 0.3.1 → 0.3.2,更新 Cargo.lock

Made-with: Cursor
2026-03-27 10:45:12 +08:00
voson
beade4503d release(secrets-mcp): v0.3.1
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m45s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- MCP: secrets_find, secrets_overview; secrets_get id-only; id on update/delete/history/rollback
- Add meta_obj/secrets_obj; delete guard; env_map/instructions updates
- Core: resolve_entry_by_id; get_*_by_id validates entry + tenant before decrypt

Made-with: Cursor
2026-03-26 17:35:56 +08:00
voson
409fd78a35 Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m39s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Rename namespace/kind to folder/type on entries, audit_log, and history tables;
  add notes. Unique key is (user_id, folder, name).
- Service layer and MCP tools support name-first lookup with optional folder when
  multiple entries share the same name.
- secrets_delete dry_run uses the same disambiguation as real deletes.
- Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and
  AGENTS.md.

Made-with: Cursor
2026-03-26 15:12:28 +08:00
voson
f7afd7f819 docs: 同步 CI 触发路径、覆盖式 tag/Release 说明与 RUST_LOG 示例
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m11s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- AGENTS.md / README:与 workflow 变更路径、远端 tag 覆盖及非 draft Release 行为一致
- deploy/.env.example:补充可选 RUST_LOG 注释

Made-with: Cursor
2026-03-22 16:15:29 +08:00
voson
719bdd7e08 feat(secrets-mcp): public home at /, login at /login (0.2.2)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Bump secrets-mcp to 0.2.2 and sync Cargo.lock.

Add home.html landing with SEO and footer link to the refining/secrets
repository; serve it at / and expose /login for sign-in.

Update OAuth error redirects and dashboard unauthenticated redirects to
/login. Improve login page meta tags, back-home link, and OAuth error
alert. Refresh llms.txt and robots.txt.

Made-with: Cursor
2026-03-22 16:11:59 +08:00
36 changed files with 4577 additions and 831 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@
.cursor/
# Google OAuth 下载的 JSON 凭据文件
client_secret_*.apps.googleusercontent.com.json
*.pem
*.pem
tmp/

View File

@@ -29,7 +29,8 @@ secrets/
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**`secrets-core``migrate`
- **Web 会话**:与上项 **同一数据库 URL**`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。
### 表结构(摘录)
@@ -37,15 +38,18 @@ secrets/
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
-- 唯一UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL
-- UNIQUE(folder, name) WHERE user_id IS NULL单租户遗留
```
```sql
@@ -82,22 +86,31 @@ oauth_accounts (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
...
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id)
)
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider同一用户每种 provider 至多一条关联。
```
### audit_log / history
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 中普通业务事件的 `namespace/kind/name` 对应 entry 坐标;登录类事件固定使用 `namespace='auth'`,此时 `kind/name` 表示认证目标而非 entry 身份。
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
### MCP 消歧AI 调用)
`name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder``secrets_delete``dry_run=true` 与真实删除使用相同消歧规则。
### 字段职责
| 字段 | 含义 | 示例 |
|------|------|------|
| `namespace` | 隔离空间 | `refining` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 标识名 | `gitea`, `i-example0…` |
| `folder` | 隔离空间(参与唯一键) | `refining` |
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` |
| `name` | 标识名 | `gitea`, `aliyun` |
| `notes` | 非敏感说明 | 自由文本 |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` |
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
@@ -105,7 +118,7 @@ oauth_accounts (
### PEM 共享(`key_ref`
将共享 PEM 存为 `kind=key` 的 entry其它记录在 `metadata.key_ref` 指向该 key 的 `name`。更新 key 记录后,引用方通过服务层解析合并逻辑即可使用新密钥(实现`secrets_core::service`
建议将共享 PEM 存为 **`type=key`** 的 entry其它记录在 `metadata.key_ref` 指向目标 entry 的 `name`(支持 `folder/name` 格式消歧)。删除被引用 key 时,服务会自动迁移为单副本 + 重定向(复制到首个引用方,其余引用方改指向新 owner解析逻辑`secrets_core::service::env_map`
## 代码规范
@@ -140,10 +153,10 @@ git tag -l 'secrets-mcp-*'
## CI/CD
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-<version>` tag则复用现有 tag 继续构建;否则由 CI 创建并推送该 tag
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock``.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tagCI 会先删后于**当前提交**重建并推送(覆盖式发版)
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release仅 tag + 构建。
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于通过 API **创建或更新**该 tag 的 Release非 draft、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release仅 tag + 构建。
- **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。
- **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。

2
Cargo.lock generated
View File

@@ -1968,7 +1968,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.2.1"
version = "0.3.8"
dependencies = [
"anyhow",
"askama",

View File

@@ -17,17 +17,47 @@ cargo build --release -p secrets-mcp
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 |
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP)。 |
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 |
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 |
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer``disable``allow``require`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
```bash
cargo run -p secrets-mcp
```
生产推荐示例PostgreSQL TLS
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
SECRETS_ENV=production
```
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)
## PostgreSQL TLS 加固
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd`
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`
- 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md)。
## MCP 与 AI 工作流v0.3+
条目在逻辑上以 **`(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然后继续删除。
## 加密架构(混合 E2EE
@@ -121,13 +151,14 @@ flowchart LR
## 数据模型
主表 **`entries`**`namespace``kind``name``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params``api_key`)、**`oauth_accounts`**。首次连库自动迁移建表。
主表 **`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 | namespace | 一级隔离,如 `refining``ricnsmart` |
| entries | kind | `server``service``key` 等(可扩展 |
| entries | name | 人类可读标识 |
| entries | folder | 组织/隔离空间,如 `refining``ricnsmart`;参与唯一键 |
| entries | type | 软分类,如 `server``service``key``person`(可扩展,不参与唯一键 |
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
| entries | notes | 非敏感说明文本 |
| entries | metadata | 明文 JSONip、url、`key_ref` 等) |
| secrets | field_name | 明文字段名,便于 schema 展示 |
| secrets | encrypted | AES-GCM 密文(含 nonce |
@@ -137,15 +168,16 @@ flowchart LR
### PEM 共享(`key_ref`
同一 PEM 可被多条 `server` 记录引用:将 PEM 存为 `kind=key` 的 entry服务器条目的 `metadata.key_ref` 中写 key 的名称;轮换时只更新 key 对应记录即可。
同一 PEM 可被多条 `server` 记录引用:建议将 PEM 存为 **`type=key`** 的 entry其它条目的 `metadata.key_ref` 中写目标 entry 的 `name`(支持 `folder/name` 格式消歧);轮换时只更新该目标记录即可。
删除共享 key 时,系统会自动迁移引用:将密文复制到首个引用方(单副本),其余引用方的 `key_ref` 自动重定向到该新 owner再删除原 key 记录。
## 审计日志
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。
其中业务条目事件使用 `[namespace/kind] name` 语义;登录类事件使用 `namespace='auth'`,此时 `kind/name` 表示认证目标(例如 `oauth/google`),不表示某条 secrets entry。
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)。
业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。
```sql
SELECT action, namespace, kind, name, detail, created_at
SELECT action, folder, type, name, detail, user_id, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
@@ -158,6 +190,7 @@ Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
scripts/
migrate-v0.3.0.sql # 可选:手动 SQL 迁移namespace/kind → folder/type、唯一键含 folder
deploy/ # systemd、.env 示例
```
@@ -165,9 +198,9 @@ deploy/ # systemd、.env 示例
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。
- **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 →`secrets-mcp-<version>` 的 tag 已存在则**复用现有 tag 继续构建**,否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp`
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz``.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 Release 与产物上传,仅保留 tag 构建结果。
- **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock``.gitea/workflows/**`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag会先删除再于**当前提交**重建并推送,覆盖式发版)
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API **创建或更新**已指向该 tag 的 Release非 draft上传 `tar.gz``.sha256`;未配置则跳过 API Release,仅 tag + 构建结果。
- **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`
- **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。

View File

@@ -3,7 +3,7 @@ use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth";
pub const FOLDER_AUTH: &str = "auth";
fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
json!({
@@ -16,7 +16,7 @@ fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str
/// Write a login audit entry without requiring an explicit transaction.
pub async fn log_login(
pool: &PgPool,
kind: &str,
entry_type: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
@@ -24,22 +24,22 @@ pub async fn log_login(
) {
let detail = login_detail(provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(NAMESPACE_AUTH)
.bind(kind)
.bind(FOLDER_AUTH)
.bind(entry_type)
.bind(provider)
.bind(&detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log");
} else {
tracing::debug!(kind, provider, ?user_id, "login audit logged");
tracing::debug!(entry_type, provider, ?user_id, "login audit logged");
}
}
@@ -48,19 +48,19 @@ pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
action: &str,
namespace: &str,
kind: &str,
folder: &str,
entry_type: &str,
name: &str,
detail: Value,
) {
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(folder)
.bind(entry_type)
.bind(name)
.bind(&detail)
.execute(&mut **tx)
@@ -69,7 +69,7 @@ pub async fn log_tx(
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, "audit logged");
tracing::debug!(action, folder, entry_type, name, "audit logged");
}
}

View File

@@ -1,4 +1,15 @@
use anyhow::Result;
use std::path::PathBuf;
use anyhow::{Context, Result};
use sqlx::postgres::PgSslMode;
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub ssl_mode: Option<PgSslMode>,
pub ssl_root_cert: Option<PathBuf>,
pub enforce_strict_tls: bool,
}
/// Resolve database URL from environment.
/// Priority: `SECRETS_DATABASE_URL` env var → error.
@@ -18,3 +29,54 @@ pub fn resolve_db_url(override_url: &str) -> Result<String> {
Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname"
)
}
fn env_var_non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn parse_ssl_mode_from_env() -> Result<Option<PgSslMode>> {
let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else {
return Ok(None);
};
let parsed = mode.parse::<PgSslMode>().with_context(|| {
format!(
"Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full."
)
})?;
Ok(Some(parsed))
}
fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else {
return Ok(None);
};
let path = PathBuf::from(path);
if !path.exists() {
anyhow::bail!(
"SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}",
path.display()
);
}
Ok(Some(path))
}
fn is_production_env() -> bool {
matches!(
env_var_non_empty("SECRETS_ENV")
.as_deref()
.map(|value| value.to_ascii_lowercase()),
Some(value) if value == "prod" || value == "production"
)
}
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
Ok(DatabaseConfig {
url: resolve_db_url(override_url)?,
ssl_mode: parse_ssl_mode_from_env()?,
ssl_root_cert: resolve_ssl_root_cert_from_env()?,
enforce_strict_tls: is_production_env(),
})
}

View File

@@ -1,14 +1,45 @@
use anyhow::Result;
use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
use crate::config::DatabaseConfig;
fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
let mut options = PgConnectOptions::from_str(&config.url)
.with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?;
if let Some(mode) = config.ssl_mode {
options = options.ssl_mode(mode);
}
if let Some(path) = &config.ssl_root_cert {
options = options.ssl_root_cert(path);
}
if config.enforce_strict_tls
&& !matches!(
options.get_ssl_mode(),
PgSslMode::VerifyCa | PgSslMode::VerifyFull
)
{
anyhow::bail!(
"Refusing to start in production with weak PostgreSQL TLS mode. \
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
);
}
Ok(options)
}
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
tracing::debug!("connecting to database");
let connect_options = build_connect_options(config)?;
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.connect_with(connect_options)
.await?;
tracing::debug!("database connection established");
Ok(pool)
@@ -22,9 +53,10 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE TABLE IF NOT EXISTS entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
@@ -34,56 +66,70 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
-- Legacy unique constraint without user_id (single-user mode)
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(namespace, kind, name)
ON entries(folder, name)
WHERE user_id IS NULL;
-- Multi-user unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, namespace, kind, name)
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace);
CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
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_entries_tags ON entries USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
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_entries_tags ON entries USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
-- ── secrets: one row per encrypted field ─────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
user_id UUID,
name VARCHAR(256) NOT NULL,
type VARCHAR(64) NOT NULL DEFAULT 'text',
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name
ON secrets(user_id, name) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type);
-- ── entry_secrets: N:N relation ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entry_secrets (
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(entry_id, secret_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
-- ── audit_log: append-only operation log ─────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID,
action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
-- ── entries_history ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
@@ -94,8 +140,8 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id
ON entries_history(entry_id, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_ns_kind_name
ON entries_history(namespace, kind, name, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
ON entries_history(folder, type, name, version DESC);
-- Backfill: add user_id to entries_history for multi-tenant isolation
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
@@ -103,29 +149,25 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
ON entries_history(user_id) WHERE user_id IS NOT NULL;
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
-- Backfill: add notes to entries if not present (fresh installs already have it)
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL,
name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id
ON secrets_history(entry_id, entry_version DESC);
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON secrets_history(secret_id);
-- Drop redundant actor column (derivable via entries_history JOIN)
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
-- Drop redundant actor column; user_id already identifies the business user
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
@@ -178,6 +220,16 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id'
) THEN
ALTER TABLE secrets
ADD CONSTRAINT fk_secrets_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
@@ -191,12 +243,179 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
)
.execute(pool)
.await?;
migrate_schema(pool).await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete");
Ok(())
}
/// Idempotent schema migration: rename namespace→folder, kind→type in existing databases.
async fn migrate_schema(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
-- ── 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 $$;
-- ── 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 $$;
-- ── entries_history: rename namespace→folder, kind→type ──────────────────
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 $$;
-- ── Set empty defaults for new folder/type columns ────────────────────────
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 $$;
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 $$;
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 $$;
-- ── Rebuild unique indexes on entries: folder is now part of the key ────────
-- (user_id, folder, name) allows same name in different folders.
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 ────────────────────────────────────
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_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);
-- ── Drop legacy actor columns ─────────────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
"#,
)
.execute(pool)
.await?;
Ok(())
}
async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
let has_users_api_key: bool = sqlx::query_scalar(
"SELECT EXISTS (
@@ -265,8 +484,8 @@ async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub namespace: &'a str,
pub kind: &'a str,
pub folder: &'a str,
pub entry_type: &'a str,
pub name: &'a str,
pub version: i64,
pub action: &'a str,
@@ -280,12 +499,12 @@ pub async fn snapshot_entry_history(
) -> Result<()> {
sqlx::query(
"INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, user_id) \
(entry_id, folder, type, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.namespace)
.bind(p.kind)
.bind(p.folder)
.bind(p.entry_type)
.bind(p.name)
.bind(p.version)
.bind(p.action)
@@ -300,10 +519,8 @@ pub async fn snapshot_entry_history(
// ── Secret field-level history snapshot ──────────────────────────────────────
pub struct SecretSnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub secret_id: uuid::Uuid,
pub entry_version: i64,
pub field_name: &'a str,
pub name: &'a str,
pub encrypted: &'a [u8],
pub action: &'a str,
}
@@ -314,13 +531,11 @@ pub async fn snapshot_secret_history(
) -> Result<()> {
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6)",
(secret_id, name, encrypted, action) \
VALUES ($1, $2, $3, $4)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.name)
.bind(p.encrypted)
.bind(p.action)
.execute(&mut **tx)

View File

@@ -4,15 +4,18 @@ use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, …).
/// A top-level entry (server, service, key, person, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub namespace: String,
pub kind: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub notes: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
@@ -24,8 +27,11 @@ pub struct Entry {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
pub user_id: Option<Uuid>,
pub name: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub secret_type: String,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
@@ -40,15 +46,47 @@ pub struct SecretField {
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
}
/// Entry row including `name` (used for id-scoped web / service updates).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryWriteRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
}
impl From<&EntryWriteRow> for EntryRow {
fn from(r: &EntryWriteRow) -> Self {
EntryRow {
id: r.id,
version: r.version,
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
tags: r.tags.clone(),
metadata: r.metadata.clone(),
notes: r.notes.clone(),
}
}
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub name: String,
pub encrypted: Vec<u8>,
}
@@ -128,10 +166,14 @@ pub struct ExportData {
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub namespace: String,
pub kind: String,
pub name: String,
#[serde(default)]
pub folder: String,
#[serde(default, rename = "type")]
pub entry_type: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
@@ -181,8 +223,10 @@ pub struct AuditLogEntry {
pub id: i64,
pub user_id: Option<Uuid>,
pub action: String,
pub namespace: String,
pub kind: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub detail: Value,
pub created_at: DateTime<Utc>,

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::collections::{BTreeSet, HashSet};
use std::fs;
use uuid::Uuid;
@@ -159,21 +160,24 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
#[derive(Debug, serde::Serialize)]
pub struct AddResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub meta_keys: Vec<String>,
pub secret_keys: Vec<String>,
}
pub struct AddParams<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub folder: &'a str,
pub entry_type: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
pub link_secret_names: &'a [String],
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
pub user_id: Option<Uuid>,
}
@@ -183,28 +187,31 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
let secret_json = build_json(params.secret_entries)?;
let meta_keys = collect_key_paths(params.meta_entries)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let flat_fields = flatten_json_fields("", &secret_json);
let new_secret_names: BTreeSet<String> =
flat_fields.iter().map(|(name, _)| name.clone()).collect();
let link_secret_names =
validate_link_secret_names(params.link_secret_names, &new_secret_names)?;
let mut tx = pool.begin().await?;
// Fetch existing entry (user-scoped or global depending on user_id)
// Fetch existing entry by (user_id, folder, name) — the natural unique key
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4",
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3",
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2",
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
@@ -216,8 +223,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
db::EntrySnapshotParams {
entry_id: ex.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
folder: params.folder,
entry_type: params.entry_type,
name: params.name,
version: ex.version,
action: "add",
@@ -232,10 +239,13 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
@@ -243,38 +253,44 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
RETURNING id"#,
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, 1, NOW())
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL
r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (folder, name) WHERE user_id IS NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
};
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
let current_entry_version: i64 =
sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
@@ -282,10 +298,10 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
folder: params.folder,
entry_type: params.entry_type,
name: params.name,
version: new_entry_version,
version: current_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
@@ -300,23 +316,25 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_name: String,
name: String,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
for f in &existing_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id,
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
name: &f.name,
encrypted: &f.encrypted,
action: "add",
},
@@ -327,29 +345,76 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
}
}
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.execute(&mut *tx)
.await?;
}
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name)
.bind(infer_secret_type(field_name))
.bind(&encrypted)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(entry_id)
.bind(secret_id)
.execute(&mut *tx)
.await?;
}
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
for link_name in &link_secret_names {
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(link_name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(link_name)
.fetch_all(&mut *tx)
.await?
};
match secret_ids.len() {
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
1 => {
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(entry_id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
crate::audit::log_tx(
&mut tx,
params.user_id,
"add",
params.namespace,
params.kind,
params.folder,
params.entry_type,
params.name,
serde_json::json!({
"tags": params.tags,
@@ -362,18 +427,65 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
tx.commit().await?;
Ok(AddResult {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.to_string(),
folder: params.folder.to_string(),
entry_type: params.entry_type.to_string(),
tags: params.tags.to_vec(),
meta_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(
link_secret_names: &[String],
new_secret_names: &BTreeSet<String>,
) -> Result<Vec<String>> {
let mut deduped = Vec::new();
let mut seen = HashSet::new();
for raw in link_secret_names {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
if new_secret_names.contains(trimmed) {
anyhow::bail!(
"Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names",
trimmed
);
}
if seen.insert(trimmed.to_string()) {
deduped.push(trimmed.to_string());
}
}
Ok(deduped)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
use std::collections::BTreeSet;
#[test]
fn parse_nested_file_shorthand() {
@@ -402,4 +514,199 @@ mod tests {
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
#[test]
fn validate_link_secret_names_conflict_with_new_secret() {
let mut new_names = BTreeSet::new();
new_names.insert("password".to_string());
let err = validate_link_secret_names(&[String::from("password")], &new_names)
.expect_err("must fail on overlap");
assert!(
err.to_string()
.contains("provided both in secrets/secrets_obj and link_secret_names")
);
}
#[test]
fn validate_link_secret_names_dedup_and_trim() {
let names = vec![
" shared_key ".to_string(),
"shared_key".to_string(),
"runner_token".to_string(),
];
let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap();
assert_eq!(deduped, vec!["shared_key", "runner_token"]);
}
async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set");
return None;
};
let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip add linkage tests: cannot connect to database");
return None;
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip add linkage tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> {
sqlx::query(
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
sqlx::query(
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
)
.bind(format!("%{marker}%"))
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn add_links_existing_secret_by_unique_name() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_unique_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id",
)
.bind(&secret_name)
.bind(vec![1_u8, 2, 3])
.fetch_one(&pool)
.await?;
run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await?;
let linked: bool = sqlx::query_scalar(
"SELECT EXISTS( \
SELECT 1 FROM entry_secrets es \
JOIN entries e ON e.id = es.entry_id \
WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \
)",
)
.bind(&entry_name)
.bind(secret_id)
.fetch_one(&pool)
.await?;
assert!(linked);
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_not_found_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_missing_{}", &suffix[..8]);
let secret_name = format!("{}_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail when linked secret is not found");
assert!(err.to_string().contains("Not found: secret named"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
#[tokio::test]
async fn add_link_secret_name_ambiguous_fails() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let suffix = Uuid::from_u128(rand::random()).to_string();
let marker = format!("link_amb_{}", &suffix[..8]);
let secret_name = format!("{}_dup_secret", marker);
let entry_name = format!("{}_entry", marker);
cleanup_test_rows(&pool, &marker).await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![1_u8])
.execute(&pool)
.await?;
sqlx::query(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
)
.bind(&secret_name)
.bind(vec![2_u8])
.execute(&pool)
.await?;
let err = run(
&pool,
AddParams {
name: &entry_name,
folder: &marker,
entry_type: "service",
notes: "",
tags: &[],
meta_entries: &[],
secret_entries: &[],
link_secret_names: std::slice::from_ref(&secret_name),
user_id: None,
},
&[0_u8; 32],
)
.await
.expect_err("must fail on ambiguous linked secret name");
assert!(err.to_string().contains("Ambiguous:"));
cleanup_test_rows(&pool, &marker).await?;
Ok(())
}
}

View File

@@ -8,7 +8,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<V
let limit = limit.clamp(1, 200);
let rows = sqlx::query_as(
"SELECT id, user_id, action, namespace, kind, name, detail, created_at \
"SELECT id, user_id, action, folder, type, name, detail, created_at \
FROM audit_log \
WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@ use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
#[allow(clippy::too_many_arguments)]
pub async fn build_env_map(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
tags: &[String],
only_fields: &[String],
@@ -21,12 +21,13 @@ pub async fn build_env_map(
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> {
let entries = fetch_entries(pool, namespace, kind, name, tags, None, user_id).await?;
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
let mut combined: HashMap<String, String> = HashMap::new();
for entry in &entries {
let entry_map = build_entry_env_map(pool, entry, only_fields, prefix, master_key).await?;
let entry_map =
build_entry_env_map(pool, entry, only_fields, prefix, master_key, user_id).await?;
combined.extend(entry_map);
}
@@ -39,6 +40,7 @@ async fn build_entry_env_map(
only_fields: &[String],
prefix: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> {
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
@@ -49,7 +51,7 @@ async fn build_entry_env_map(
} else {
all_fields
.iter()
.filter(|f| only_fields.contains(&f.field_name))
.filter(|f| only_fields.contains(&f.name))
.collect()
};
@@ -61,23 +63,28 @@ async fn build_entry_env_map(
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
f.name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_to_env_string(&decrypted));
}
// Resolve key_ref
// 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 key_entries = fetch_entries(
pool,
Some(&entry.namespace),
Some("key"),
Some(key_ref),
&[],
None,
None,
)
.await?;
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];
@@ -90,12 +97,12 @@ async fn build_entry_env_map(
let key_var = format!(
"{}_{}",
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
f.name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key_var, json_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, "key_ref target not found");
tracing::warn!(key_ref, ?user_id, "key_ref target not found");
}
}

View File

@@ -9,8 +9,8 @@ use crate::models::{ExportData, ExportEntry, ExportFormat};
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
pub struct ExportParams<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
@@ -25,8 +25,8 @@ pub async fn export(
) -> Result<ExportData> {
let entries = fetch_entries(
pool,
params.namespace,
params.kind,
params.folder,
params.entry_type,
params.name,
params.tags,
params.query,
@@ -55,16 +55,17 @@ pub async fn export(
let mut map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
map.insert(f.name.clone(), decrypted);
}
Some(map)
}
};
export_entries.push(ExportEntry {
namespace: entry.namespace.clone(),
kind: entry.kind.clone(),
name: entry.name.clone(),
folder: entry.folder.clone(),
entry_type: entry.entry_type.clone(),
notes: entry.notes.clone(),
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,

View File

@@ -5,31 +5,19 @@ use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
/// Decrypt a single named field from an entry.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_secret_field(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
folder: Option<&str>,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
let entry = resolve_entry(pool, name, folder, user_id).await?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
@@ -37,34 +25,22 @@ pub async fn get_secret_field(
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.find(|f| f.name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
}
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_all_secrets(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
folder: Option<&str>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
let entry = resolve_entry(pool, name, folder, user_id).await?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
@@ -73,7 +49,56 @@ pub async fn get_all_secrets(
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
map.insert(f.name.clone(), decrypted);
}
Ok(map)
}
/// Decrypt a single named field from an entry, located by its UUID.
pub async fn get_secret_field_by_id(
pool: &PgPool,
entry_id: Uuid,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
let entry_ids = vec![entry_id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
let field = fields
.iter()
.find(|f| f.name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
}
/// Decrypt all secret fields from an entry, located by its UUID.
/// Returns a map field_name → decrypted Value.
pub async fn get_all_secrets_by_id(
pool: &PgPool,
entry_id: Uuid,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
// Validate entry exists (and that it belongs to the requesting user)
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
let entry_ids = vec![entry_id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.name.clone(), decrypted);
}
Ok(map)
}

View File

@@ -3,6 +3,8 @@ use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::service::search::resolve_entry;
#[derive(Debug, serde::Serialize)]
pub struct HistoryEntry {
pub version: i64,
@@ -10,11 +12,12 @@ pub struct HistoryEntry {
pub created_at: String,
}
/// Return version history for the entry identified by `name`.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
folder: Option<&str>,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Vec<HistoryEntry>> {
@@ -25,32 +28,16 @@ pub async fn run(
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.bind(limit as i64)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?
};
let entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
)
.bind(entry.id)
.bind(limit as i64)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
@@ -64,12 +51,11 @@ pub async fn run(
pub async fn run_json(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
folder: Option<&str>,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = run(pool, namespace, kind, name, limit, user_id).await?;
let entries = run(pool, name, folder, limit, user_id).await?;
Ok(serde_json::to_value(entries)?)
}

View File

@@ -47,10 +47,9 @@ pub async fn run(
for entry in &data.entries {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NOT DISTINCT FROM $4)",
WHERE folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)",
)
.bind(&entry.namespace)
.bind(&entry.kind)
.bind(&entry.folder)
.bind(&entry.name)
.bind(params.user_id)
.fetch_one(pool)
@@ -59,9 +58,7 @@ pub async fn run(
if exists && !params.force {
return Err(anyhow::anyhow!(
"Import aborted: conflict on [{}/{}/{}]",
entry.namespace,
entry.kind,
"Import aborted: conflict on '{}'",
entry.name
));
}
@@ -81,12 +78,14 @@ pub async fn run(
match add_run(
pool,
AddParams {
namespace: &entry.namespace,
kind: &entry.kind,
name: &entry.name,
folder: &entry.folder,
entry_type: &entry.entry_type,
notes: &entry.notes,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
link_secret_names: &[],
user_id: params.user_id,
},
master_key,
@@ -98,8 +97,6 @@ pub async fn run(
}
Err(e) => {
tracing::error!(
namespace = entry.namespace,
kind = entry.kind,
name = entry.name,
error = %e,
"failed to import entry"

View File

@@ -3,92 +3,145 @@ use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub restored_version: i64,
}
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
folder: Option<&str>,
to_version: Option<i64>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id = $5 ORDER BY id DESC LIMIT 1",
// Disambiguate: find the unique entry_id for (name, folder).
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.bind(f)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.fetch_optional(pool)
.await?
.bind(uid)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
}
} else if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id = $4 ORDER BY id DESC LIMIT 1",
} else if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.bind(f)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
};
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
"SELECT folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(entry_id)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
namespace,
kind,
"No history found for '{}'{}.",
name,
to_version
.map(|v| format!(" at version {}", v))
@@ -96,33 +149,7 @@ pub async fn run(
)
})?;
#[derive(sqlx::FromRow)]
struct SecretHistoryRow {
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT field_name, encrypted, action FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
)
.bind(snap.entry_id)
.bind(snap.version)
.fetch_all(pool)
.await?;
for f in &field_snaps {
if f.action != "delete" && !f.encrypted.is_empty() {
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
anyhow::anyhow!(
"Cannot decrypt snapshot for field '{}': {}",
f.field_name,
e
)
})?;
}
}
let _ = master_key;
let mut tx = pool.begin().await?;
@@ -130,43 +157,32 @@ pub async fn run(
struct LiveEntry {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
tags: Vec<String>,
metadata: Value,
#[allow(dead_code)]
notes: String,
}
// Query live entry with correct user_id scoping to avoid PK conflicts
let live: Option<LiveEntry> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
// Lock the live entry if it exists (matched by entry_id for precision).
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE id = $1 FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?;
let entry_id = if let Some(ref lr) = live {
// Snapshot current state before overwriting
let live_entry_id = if let Some(ref lr) = live {
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
namespace,
kind,
folder: &lr.folder,
entry_type: &lr.entry_type,
name,
version: lr.version,
action: "rollback",
@@ -182,23 +198,25 @@ pub async fn run(
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
field_name: String,
name: String,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
for f in &live_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: lr.id,
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
name: &f.name,
encrypted: &f.encrypted,
action: "rollback",
},
@@ -209,7 +227,6 @@ pub async fn run(
}
}
// Update the existing row in-place to preserve its primary key and user_id
sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
updated_at = NOW() WHERE id = $3",
@@ -222,16 +239,15 @@ pub async fn run(
lr.id
} else {
// No live entry — insert a fresh one with a new UUID
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
@@ -241,11 +257,11 @@ pub async fn run(
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
(folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
)
.bind(namespace)
.bind(kind)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
@@ -255,29 +271,16 @@ pub async fn run(
}
};
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
continue;
}
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
// In N:N mode, rollback restores entry metadata/tags only.
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here.
let _ = live_entry_id;
crate::audit::log_tx(
&mut tx,
user_id,
"rollback",
namespace,
kind,
&snap.folder,
&snap.entry_type,
name,
serde_json::json!({
"restored_version": snap.version,
@@ -289,9 +292,9 @@ pub async fn run(
tx.commit().await?;
Ok(RollbackResult {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,
})
}

View File

@@ -9,8 +9,8 @@ use crate::models::{Entry, SecretField};
pub const FETCH_ALL_LIMIT: u32 = 100_000;
pub struct SearchParams<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
@@ -27,49 +27,46 @@ pub struct SearchResult {
pub secret_schemas: HashMap<Uuid, Vec<SecretField>>,
}
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
let entries = fetch_entries_paged(pool, &params).await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secret_schemas = if !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
Ok(SearchResult {
entries,
secret_schemas,
})
}
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
pub async fn fetch_entries(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
/// List `entries` rows matching params (paged, ordered per `params.sort`).
/// Does not read the `secrets` table.
pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result<Vec<Entry>> {
fetch_entries_paged(pool, &params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`).
/// Does not read the `secrets` table.
pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
let (where_clause, _) = entry_where_clause_and_next_idx(a);
let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}");
let mut q = sqlx::query_scalar::<_, i64>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.folder {
q = q.bind(v);
}
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
q = q.bind(pattern);
}
let n = q.fetch_one(pool).await?;
Ok(n)
}
/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries).
fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
// user_id filtering — always comes first when present
if a.user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
@@ -77,12 +74,12 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
conditions.push("user_id IS NULL".to_string());
}
if a.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
if a.folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if a.kind.is_some() {
conditions.push(format!("kind = ${}", idx));
if a.entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
idx += 1;
}
if a.name.is_some() {
@@ -106,14 +103,64 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
}
if a.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' \
OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' \
"(name ILIKE ${i} ESCAPE '\\' OR folder ILIKE ${i} ESCAPE '\\' \
OR type ILIKE ${i} ESCAPE '\\' OR notes ILIKE ${i} ESCAPE '\\' \
OR metadata::text ILIKE ${i} ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, idx)
}
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
let entries = fetch_entries_paged(pool, &params).await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secret_schemas = if !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
Ok(SearchResult {
entries,
secret_schemas,
})
}
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
pub async fn fetch_entries(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
folder,
entry_type,
name,
tags,
query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
list_entries(pool, params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
let (where_clause, idx) = entry_where_clause_and_next_idx(a);
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
@@ -121,30 +168,22 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
};
let limit_idx = idx;
idx += 1;
let offset_idx = idx;
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let offset_idx = idx + 1;
let sql = format!(
"SELECT id, user_id, \
namespace, kind, name, tags, metadata, version, created_at, updated_at \
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
);
let mut q = sqlx::query_as::<_, EntryRaw>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.namespace {
if let Some(v) = a.folder {
q = q.bind(v);
}
if let Some(v) = a.kind {
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
@@ -171,8 +210,12 @@ pub async fn fetch_secret_schemas(
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
let fields: Vec<EntrySecretRow> = sqlx::query_as(
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = ANY($1) \
ORDER BY es.entry_id, es.sort_order, s.name",
)
.bind(entry_ids)
.fetch_all(pool)
@@ -180,7 +223,8 @@ pub async fn fetch_secret_schemas(
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
let entry_id = f.entry_id;
map.entry(entry_id).or_default().push(f.secret());
}
Ok(map)
}
@@ -193,8 +237,12 @@ pub async fn fetch_secrets_for_entries(
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
let fields: Vec<EntrySecretRow> = sqlx::query_as(
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = ANY($1) \
ORDER BY es.entry_id, es.sort_order, s.name",
)
.bind(entry_ids)
.fetch_all(pool)
@@ -202,20 +250,87 @@ pub async fn fetch_secrets_for_entries(
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
let entry_id = f.entry_id;
map.entry(entry_id).or_default().push(f.secret());
}
Ok(map)
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
/// Resolve exactly one entry by its UUID primary key.
///
/// Returns an error if the entry does not exist or does not belong to the given user.
pub async fn resolve_entry_by_id(
pool: &PgPool,
id: Uuid,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let row: Option<EntryRaw> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
)
.bind(id)
.fetch_optional(pool)
.await?
};
row.map(Entry::from)
.ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id))
}
/// Resolve exactly one entry by name, with optional folder for disambiguation.
///
/// - If `folder` is provided: exact `(folder, name)` match.
/// - If `folder` is None and exactly one entry matches: returns it.
/// - If `folder` is None and multiple entries match: returns an error listing
/// the folders and asking the caller to specify one.
pub async fn resolve_entry(
pool: &PgPool,
name: &str,
folder: Option<&str>,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
match entries.len() {
0 => {
if let Some(f) = folder {
anyhow::bail!("Not found: '{}' in folder '{}'", name, f)
} else {
anyhow::bail!("Not found: '{}'", name)
}
}
1 => Ok(entries.into_iter().next().unwrap()),
_ => {
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
entries.len(),
name,
folders.join(", ")
)
}
}
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
#[derive(sqlx::FromRow)]
struct EntryRaw {
id: Uuid,
user_id: Option<Uuid>,
namespace: String,
kind: String,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
notes: String,
tags: Vec<String>,
metadata: Value,
version: i64,
@@ -228,9 +343,10 @@ impl From<EntryRaw> for Entry {
Entry {
id: r.id,
user_id: r.user_id,
namespace: r.namespace,
kind: r.kind,
folder: r.folder,
entry_type: r.entry_type,
name: r.name,
notes: r.notes,
tags: r.tags,
metadata: r.metadata,
version: r.version,
@@ -239,3 +355,32 @@ impl From<EntryRaw> for Entry {
}
}
}
#[derive(sqlx::FromRow)]
struct EntrySecretRow {
entry_id: Uuid,
id: Uuid,
user_id: Option<Uuid>,
name: String,
#[sqlx(rename = "type")]
secret_type: String,
encrypted: Vec<u8>,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl EntrySecretRow {
fn secret(self) -> SecretField {
SecretField {
id: self.id,
user_id: self.user_id,
name: self.name,
secret_type: self.secret_type,
encrypted: self.encrypted,
version: self.version,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}

View File

@@ -5,17 +5,18 @@ use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::models::{EntryRow, EntryWriteRow};
use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
collect_field_paths, collect_key_paths, flatten_json_fields, infer_secret_type, insert_path,
parse_key_path, parse_kv, remove_path,
};
#[derive(Debug, serde::Serialize)]
pub struct UpdateResult {
pub namespace: String,
pub kind: String,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub add_tags: Vec<String>,
pub remove_tags: Vec<String>,
pub meta_keys: Vec<String>,
@@ -25,9 +26,10 @@ pub struct UpdateResult {
}
pub struct UpdateParams<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
/// Optional folder for disambiguation when multiple entries share the same name.
pub folder: Option<&'a str>,
pub notes: Option<&'a str>,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
@@ -44,45 +46,76 @@ pub async fn run(
) -> Result<UpdateResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = if let Some(uid) = params.user_id {
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
)
.bind(uid)
.bind(folder)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
}
} else if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.fetch_all(&mut *tx)
.await?
};
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
params.namespace,
params.kind,
params.name
)
})?;
let row = match rows.len() {
0 => {
tx.rollback().await?;
anyhow::bail!(
"Not found: '{}'. Use `add` to create it first.",
params.name
)
}
1 => rows.into_iter().next().unwrap(),
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
params.name,
folders.join(", ")
)
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
folder: &row.folder,
entry_type: &row.entry_type,
name: params.name,
version: row.version,
action: "update",
@@ -117,12 +150,16 @@ pub async fn run(
}
let metadata = Value::Object(meta_map);
let new_notes = params.notes.unwrap_or(&row.notes);
let result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3 AND version = $4",
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
version = version + 1, updated_at = NOW() \
WHERE id = $4 AND version = $5",
)
.bind(&tags)
.bind(&metadata)
.bind(new_notes)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
@@ -131,15 +168,11 @@ pub async fn run(
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for [{}/{}] {}. Please retry.",
params.namespace,
params.kind,
"Concurrent modification detected for '{}'. Please retry.",
params.name
);
}
let new_version = row.version + 1;
for entry in params.secret_entries {
let (path, field_value) = parse_kv(entry)?;
let flat = flatten_json_fields("", &{
@@ -157,7 +190,10 @@ pub async fn run(
encrypted: Vec<u8>,
}
let ef: Option<ExistingField> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(field_name)
@@ -168,10 +204,8 @@ pub async fn run(
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
name: field_name,
encrypted: &ef.encrypted,
action: "update",
},
@@ -181,16 +215,30 @@ pub async fn run(
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
if let Some(ef) = ef {
sqlx::query(
"UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2",
)
.bind(&encrypted)
.bind(ef.id)
.execute(&mut *tx)
.await?;
} else {
let secret_id: Uuid = sqlx::query_scalar(
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(params.user_id)
.bind(field_name)
.bind(infer_secret_type(field_name))
.bind(&encrypted)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
.bind(row.id)
.bind(secret_id)
.execute(&mut *tx)
.await?;
}
}
}
@@ -204,7 +252,10 @@ pub async fn run(
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(&field_name)
@@ -215,10 +266,8 @@ pub async fn run(
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
name: &field_name,
encrypted: &f.encrypted,
action: "delete",
},
@@ -227,10 +276,19 @@ pub async fn run(
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.id)
.bind(f.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
@@ -243,8 +301,8 @@ pub async fn run(
&mut tx,
params.user_id,
"update",
params.namespace,
params.kind,
"",
"",
params.name,
serde_json::json!({
"add_tags": params.add_tags,
@@ -260,9 +318,9 @@ pub async fn run(
tx.commit().await?;
Ok(UpdateResult {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.to_string(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
add_tags: params.add_tags.to_vec(),
remove_tags: params.remove_tags.to_vec(),
meta_keys,
@@ -271,3 +329,118 @@ pub async fn run(
remove_secrets: remove_secret_keys,
})
}
/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match).
/// Does not read or modify `secrets` rows.
pub struct UpdateEntryFieldsByIdParams<'a> {
pub folder: &'a str,
pub entry_type: &'a str,
pub name: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub metadata: &'a serde_json::Value,
}
pub async fn update_fields_by_id(
pool: &PgPool,
entry_id: Uuid,
user_id: Uuid,
params: UpdateEntryFieldsByIdParams<'_>,
) -> Result<()> {
if params.folder.len() > 128 {
anyhow::bail!("folder must be at most 128 characters");
}
if params.entry_type.len() > 64 {
anyhow::bail!("type must be at most 64 characters");
}
if params.name.len() > 256 {
anyhow::bail!("name must be at most 256 characters");
}
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
WHERE id = $1 AND user_id = $2 FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
anyhow::bail!("Entry not found");
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: Some(user_id),
folder: &row.folder,
entry_type: &row.entry_type,
name: &row.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
}
let res = sqlx::query(
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() \
WHERE id = $7 AND version = $8",
)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(params.metadata)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref d) = e
&& d.code().as_deref() == Some("23505")
{
return anyhow::anyhow!(
"An entry with this folder and name already exists for your account."
);
}
e.into()
})?;
if res.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!("Concurrent modification detected. Please refresh and try again.");
}
crate::audit::log_tx(
&mut tx,
Some(user_id),
"update",
params.folder,
params.entry_type,
params.name,
serde_json::json!({
"source": "web",
"entry_id": entry_id,
"fields": ["folder", "type", "name", "notes", "tags", "metadata"],
}),
)
.await;
tx.commit().await?;
Ok(())
}

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.2.1"
version = "0.3.8"
edition.workspace = true
[[bin]]

View File

@@ -21,7 +21,7 @@ use tower_sessions_sqlx_store_chrono::PostgresStore;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_url;
use secrets_core::config::resolve_db_config;
use secrets_core::db::{create_pool, migrate};
use crate::oauth::OAuthConfig;
@@ -78,9 +78,9 @@ async fn main() -> Result<()> {
.init();
// ── Database ──────────────────────────────────────────────────────────────
let db_url = resolve_db_url("")
let db_config = resolve_db_config("")
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
let pool = create_pool(&db_url)
let pool = create_pool(&db_config)
.await
.context("failed to connect to database")?;
migrate(&pool)

View File

@@ -14,6 +14,7 @@ use rmcp::{
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
@@ -22,10 +23,10 @@ use secrets_core::service::{
add::{AddParams, run as svc_add},
delete::{DeleteParams, run as svc_delete},
export::{ExportParams, export as svc_export},
get_secret::{get_all_secrets, get_secret_field},
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
history::run as svc_history,
rollback::run as svc_rollback,
search::{SearchParams, run as svc_search},
search::{SearchParams, resolve_entry_by_id, run as svc_search},
update::{UpdateParams, run as svc_update},
};
@@ -154,18 +155,38 @@ impl SecretsService {
// ── Tool parameter types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchInput {
#[schemars(description = "Namespace filter (e.g. 'refining', 'ricnsmart')")]
namespace: Option<String>,
#[schemars(description = "Kind filter (e.g. 'server', 'service', 'key')")]
kind: Option<String>,
#[schemars(description = "Exact record name")]
struct FindInput {
#[schemars(
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
)]
query: Option<String>,
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
folder: Option<String>,
#[schemars(description = "Exact type filter (e.g. 'server', 'service', 'person', 'key')")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy search across name, namespace, kind, tags, metadata")]
#[schemars(description = "Max results (default 20)")]
limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchInput {
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
query: Option<String>,
#[schemars(description = "Return only summary fields (name/tags/desc/updated_at)")]
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
folder: Option<String>,
#[schemars(description = "Type filter (e.g. 'server', 'service', 'person', 'key')")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name to match")]
name: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
summary: Option<bool>,
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
@@ -177,96 +198,143 @@ struct SearchInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct GetSecretInput {
#[schemars(description = "Namespace of the entry")]
namespace: String,
#[schemars(description = "Kind of the entry (e.g. 'server', 'service')")]
kind: String,
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(description = "Entry UUID obtained from secrets_find results")]
id: String,
#[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
field: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AddInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind (e.g. 'server', 'service', 'key')")]
kind: String,
#[schemars(description = "Unique name within namespace+kind")]
#[schemars(description = "Unique name for this entry")]
name: String,
#[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
folder: Option<String>,
#[schemars(
description = "Type/category of this entry (optional, e.g. 'server', 'person', 'key')"
)]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Free-text notes for this entry (optional)")]
notes: Option<String>,
#[schemars(description = "Tags for this entry")]
tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
meta: Option<Vec<String>>,
#[schemars(description = "Secret fields as 'key=value' strings")]
#[schemars(
description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
)]
meta_obj: Option<Map<String, Value>>,
#[schemars(
description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
)]
secrets: Option<Vec<String>>,
#[schemars(
description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
)]
secrets_obj: Option<Map<String, Value>>,
#[schemars(
description = "Link existing secrets by secret name. Names must resolve uniquely under current user."
)]
link_secret_names: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct UpdateInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry to update")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(
description = "Entry UUID (from secrets_find). If provided, name/folder are used for disambiguation only."
)]
id: Option<String>,
#[schemars(description = "Update the notes field")]
notes: Option<String>,
#[schemars(description = "Tags to add")]
add_tags: Option<Vec<String>>,
#[schemars(description = "Tags to remove")]
remove_tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
meta: Option<Vec<String>>,
#[schemars(
description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided."
)]
meta_obj: Option<Map<String, Value>>,
#[schemars(description = "Metadata field keys to remove")]
remove_meta: Option<Vec<String>>,
#[schemars(description = "Secret fields to update/add as 'key=value' strings")]
#[schemars(
description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets."
)]
secrets: Option<Vec<String>>,
#[schemars(
description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address."
)]
secrets_obj: Option<Map<String, Value>>,
#[schemars(description = "Secret field keys to remove")]
remove_secrets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct DeleteInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind filter (required for single delete)")]
kind: Option<String>,
#[schemars(description = "Exact name to delete. Omit for bulk delete by namespace+kind.")]
#[schemars(
description = "Entry UUID (from secrets_find). If provided, deletes this specific entry \
regardless of name/folder."
)]
id: Option<String>,
#[schemars(description = "Name of the entry to delete (single delete). \
Omit to bulk delete by folder/type filters.")]
name: Option<String>,
#[schemars(description = "Folder filter for bulk delete")]
folder: Option<String>,
#[schemars(description = "Type filter for bulk delete")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Preview deletions without writing")]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct HistoryInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
)]
id: Option<String>,
#[schemars(description = "Max history entries to return (default 20)")]
limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
)]
id: Option<String>,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
to_version: Option<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ExportInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Folder filter")]
folder: Option<String>,
#[schemars(description = "Type filter")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
@@ -279,27 +347,139 @@ struct ExportInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct EnvMapInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Folder filter")]
folder: Option<String>,
#[schemars(description = "Type filter")]
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
tags: Option<Vec<String>>,
#[schemars(description = "Only include these secret fields")]
only_fields: Option<Vec<String>>,
#[schemars(description = "Environment variable name prefix")]
#[schemars(description = "Environment variable name prefix. \
Variable names are built as UPPER(prefix)_UPPER(entry_name)_UPPER(field_name), \
with hyphens and dots replaced by underscores. \
Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID \
(or PREFIX_ALIYUN_ACCESS_KEY_ID with prefix set).")]
prefix: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OverviewInput {}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Convert a JSON object map into "key=value" / "key:=json" strings for service-layer parsing.
fn map_to_kv_strings(map: Map<String, Value>) -> Vec<String> {
map.into_iter()
.map(|(k, v)| match &v {
Value::String(s) => format!("{}={}", k, s),
_ => format!("{}:={}", k, v),
})
.collect()
}
/// Parse a UUID string, returning an MCP error on failure.
fn parse_uuid(s: &str) -> Result<Uuid, rmcp::ErrorData> {
s.parse::<Uuid>()
.map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None))
}
// ── Tool implementations ──────────────────────────────────────────────────────
#[tool_router]
impl SecretsService {
#[tool(
description = "Find entries in the secrets store by folder, name, type, tags, or a \
fuzzy query that also searches metadata values. Requires Bearer API key. \
Returns 0 or more entries with id, metadata, and secret field names (not values). \
Use the returned id with secrets_get to decrypt secret values. \
Replaces secrets_search for discovery tasks.",
annotations(title = "Find Secrets", read_only_hint = true, idempotent_hint = true)
)]
async fn secrets_find(
&self,
Parameters(input): Parameters<FindInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::require_user_id(&ctx)?;
tracing::info!(
tool = "secrets_find",
?user_id,
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
name = input.name.as_deref(),
query = input.query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
let result = svc_search(
&self.pool,
SearchParams {
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
sort: "name",
limit: input.limit.unwrap_or(20),
offset: 0,
user_id: Some(user_id),
},
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
let schema: Vec<serde_json::Value> = result
.secret_schemas
.get(&e.id)
.map(|f| {
f.iter()
.map(|s| {
serde_json::json!({
"id": s.id,
"name": s.name,
"type": s.secret_type,
})
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"id": e.id,
"name": e.name,
"folder": e.folder,
"type": e.entry_type,
"tags": e.tags,
"metadata": e.metadata,
"secret_fields": schema,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
})
.collect();
tracing::info!(
tool = "secrets_find",
?user_id,
result_count = entries.len(),
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Search entries in the secrets store. Requires Bearer API key. Returns \
entries with metadata and secret field names (not values). Use secrets_get to decrypt secret values.",
entries with metadata and secret field names (not values). \
Prefer secrets_find for discovery; secrets_search is kept for backward compatibility.",
annotations(
title = "Search Secrets",
read_only_hint = true,
@@ -316,8 +496,8 @@ impl SecretsService {
tracing::info!(
tool = "secrets_search",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
name = input.name.as_deref(),
query = input.query.as_deref(),
"tool call start",
@@ -326,8 +506,8 @@ impl SecretsService {
let result = svc_search(
&self.pool,
SearchParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
@@ -347,25 +527,35 @@ impl SecretsService {
.map(|e| {
if summary {
serde_json::json!({
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"folder": e.folder,
"type": e.entry_type,
"tags": e.tags,
"desc": e.metadata.get("desc").or_else(|| e.metadata.get("url"))
.and_then(|v| v.as_str()).unwrap_or(""),
"notes": e.notes,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
let schema: Vec<&str> = result
let schema: Vec<serde_json::Value> = result
.secret_schemas
.get(&e.id)
.map(|f| f.iter().map(|s| s.field_name.as_str()).collect())
.map(|f| {
f.iter()
.map(|s| {
serde_json::json!({
"id": s.id,
"name": s.name,
"type": s.secret_type,
})
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"id": e.id,
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"folder": e.folder,
"type": e.entry_type,
"notes": e.notes,
"tags": e.tags,
"metadata": e.metadata,
"secret_fields": schema,
@@ -389,8 +579,8 @@ impl SecretsService {
}
#[tool(
description = "Get decrypted secret field values for an entry. Requires your \
encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \
description = "Get decrypted secret field values for an entry identified by its UUID \
(from secrets_find). Requires X-Encryption-Key header. \
Returns all fields, or a specific field if 'field' is provided.",
annotations(
title = "Get Secret Values",
@@ -405,32 +595,23 @@ impl SecretsService {
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let entry_id = parse_uuid(&input.id)?;
tracing::info!(
tool = "secrets_get",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
id = %input.id,
field = input.field.as_deref(),
"tool call start",
);
if let Some(field_name) = &input.field {
let value = get_secret_field(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
field_name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
let value =
get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
tracing::info!(
tool = "secrets_get",
?user_id,
id = %input.id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
@@ -438,22 +619,14 @@ impl SecretsService {
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
} else {
let secrets = get_all_secrets(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?;
let count = secrets.len();
tracing::info!(
tool = "secrets_get",
?user_id,
field_count = count,
tool = "secrets_get",
id = %entry_id,
field_count = secrets.len(),
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
@@ -465,7 +638,8 @@ impl SecretsService {
#[tool(
description = "Add or upsert an entry with metadata and encrypted secret fields. \
Requires X-Encryption-Key header. \
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format.",
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format, \
or pass a JSON object via meta_obj / secrets_obj.",
annotations(title = "Add Secret Entry")
)]
async fn secrets_add(
@@ -478,25 +652,37 @@ impl SecretsService {
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let mut meta = input.meta.unwrap_or_default();
if let Some(obj) = input.meta_obj {
meta.extend(map_to_kv_strings(obj));
}
let mut secrets = input.secrets.unwrap_or_default();
if let Some(obj) = input.secrets_obj {
secrets.extend(map_to_kv_strings(obj));
}
let link_secret_names = input.link_secret_names.unwrap_or_default();
let folder = input.folder.as_deref().unwrap_or("");
let entry_type = input.entry_type.as_deref().unwrap_or("");
let notes = input.notes.as_deref().unwrap_or("");
let result = svc_add(
&self.pool,
AddParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
folder,
entry_type,
notes,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
link_secret_names: &link_secret_names,
user_id: Some(user_id),
},
&user_key,
@@ -507,8 +693,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
@@ -519,7 +703,8 @@ impl SecretsService {
#[tool(
description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
Only the fields you specify are changed; everything else is preserved.",
Only the fields you specify are changed; everything else is preserved. \
Optionally pass 'id' (from secrets_find) to target the entry directly.",
annotations(title = "Update Secret Entry")
)]
async fn secrets_update(
@@ -532,25 +717,42 @@ impl SecretsService {
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
id = ?input.id,
"tool call start",
);
// When id is provided, resolve to (name, folder) via primary key to skip disambiguation.
let (resolved_name, resolved_folder): (String, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?;
(entry.name, Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let add_tags = input.add_tags.unwrap_or_default();
let remove_tags = input.remove_tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let mut meta = input.meta.unwrap_or_default();
if let Some(obj) = input.meta_obj {
meta.extend(map_to_kv_strings(obj));
}
let remove_meta = input.remove_meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let mut secrets = input.secrets.unwrap_or_default();
if let Some(obj) = input.secrets_obj {
secrets.extend(map_to_kv_strings(obj));
}
let remove_secrets = input.remove_secrets.unwrap_or_default();
let result = svc_update(
&self.pool,
UpdateParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
name: &resolved_name,
folder: resolved_folder.as_deref(),
notes: input.notes.as_deref(),
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
@@ -567,9 +769,7 @@ impl SecretsService {
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
name = %resolved_name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
@@ -578,8 +778,9 @@ impl SecretsService {
}
#[tool(
description = "Delete one entry (specify namespace+kind+name) or bulk delete all \
entries matching namespace+kind. Use dry_run=true to preview.",
description = "Delete one entry by name (or id), or bulk delete entries matching folder \
and/or type. Use dry_run=true to preview. \
At least one of id, name, folder, or type must be provided.",
annotations(title = "Delete Secret Entry", destructive_hint = true)
)]
async fn secrets_delete(
@@ -589,22 +790,49 @@ impl SecretsService {
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?;
// Safety: require at least one filter.
if input.id.is_none()
&& input.name.is_none()
&& input.folder.is_none()
&& input.entry_type.is_none()
{
return Err(rmcp::ErrorData::invalid_request(
"At least one of id, name, folder, or type must be provided.",
None,
));
}
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
kind = input.kind.as_deref(),
id = ?input.id,
name = input.name.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
dry_run = input.dry_run.unwrap_or(false),
"tool call start",
);
// When id is provided, resolve to name+folder for the single-entry delete path.
let (effective_name, effective_folder): (Option<String>, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let uid = user_id;
let entry = resolve_entry_by_id(&self.pool, eid, uid)
.await
.map_err(|e| mcp_err_internal_logged("secrets_delete", uid, e))?;
(Some(entry.name), Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let result = svc_delete(
&self.pool,
DeleteParams {
namespace: &input.namespace,
kind: input.kind.as_deref(),
name: input.name.as_deref(),
name: effective_name.as_deref(),
folder: effective_folder.as_deref(),
entry_type: input.entry_type.as_deref(),
dry_run: input.dry_run.unwrap_or(false),
user_id,
},
@@ -615,7 +843,6 @@ impl SecretsService {
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
@@ -625,7 +852,7 @@ impl SecretsService {
#[tool(
description = "View change history for an entry. Returns a list of versions with \
actions and timestamps.",
actions and timestamps. Optionally pass 'id' (from secrets_find) to target directly.",
annotations(
title = "View Secret History",
read_only_hint = true,
@@ -642,17 +869,26 @@ impl SecretsService {
tracing::info!(
tool = "secrets_history",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
id = ?input.id,
"tool call start",
);
let (resolved_name, resolved_folder): (String, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let entry = resolve_entry_by_id(&self.pool, eid, user_id)
.await
.map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?;
(entry.name, Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let result = svc_history(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
&resolved_name,
resolved_folder.as_deref(),
input.limit.unwrap_or(20),
user_id,
)
@@ -671,7 +907,8 @@ impl SecretsService {
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
Omit to_version to restore the most recent snapshot.",
Omit to_version to restore the most recent snapshot. \
Optionally pass 'id' (from secrets_find) to target directly.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
)]
async fn secrets_rollback(
@@ -684,18 +921,27 @@ impl SecretsService {
tracing::info!(
tool = "secrets_rollback",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
id = ?input.id,
to_version = input.to_version,
"tool call start",
);
let (resolved_name, resolved_folder): (String, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
(entry.name, Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let result = svc_rollback(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
&resolved_name,
resolved_folder.as_deref(),
input.to_version,
&user_key,
Some(user_id),
@@ -734,8 +980,8 @@ impl SecretsService {
tracing::info!(
tool = "secrets_export",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
format,
"tool call start",
);
@@ -743,8 +989,8 @@ impl SecretsService {
let data = svc_export(
&self.pool,
ExportParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
@@ -785,7 +1031,10 @@ impl SecretsService {
#[tool(
description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection.",
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection. \
Variable names follow the pattern UPPER(entry_name)_UPPER(field_name), \
with hyphens and dots replaced by underscores. \
Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID.",
annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true)
)]
async fn secrets_env_map(
@@ -800,16 +1049,16 @@ impl SecretsService {
tracing::info!(
tool = "secrets_env_map",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
folder = input.folder.as_deref(),
entry_type = input.entry_type.as_deref(),
prefix = input.prefix.as_deref().unwrap_or(""),
"tool call start",
);
let env_map = secrets_core::service::env_map::build_env_map(
&self.pool,
input.namespace.as_deref(),
input.kind.as_deref(),
input.folder.as_deref(),
input.entry_type.as_deref(),
input.name.as_deref(),
&tags,
&only_fields,
@@ -831,6 +1080,67 @@ impl SecretsService {
let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Get an overview of the secrets store: counts of entries per folder and \
per type. Requires Bearer API key. Useful for exploring the store structure.",
annotations(
title = "Secrets Overview",
read_only_hint = true,
idempotent_hint = true
)
)]
async fn secrets_overview(
&self,
Parameters(_input): Parameters<OverviewInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::require_user_id(&ctx)?;
tracing::info!(tool = "secrets_overview", ?user_id, "tool call start");
#[derive(sqlx::FromRow)]
struct CountRow {
name: String,
count: i64,
}
let folder_rows: Vec<CountRow> = sqlx::query_as(
"SELECT folder AS name, COUNT(*) AS count FROM entries \
WHERE user_id = $1 GROUP BY folder ORDER BY folder",
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
let type_rows: Vec<CountRow> = sqlx::query_as(
"SELECT type AS name, COUNT(*) AS count FROM entries \
WHERE user_id = $1 GROUP BY type ORDER BY type",
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
let total: i64 = folder_rows.iter().map(|r| r.count).sum();
let result = serde_json::json!({
"total": total,
"folders": folder_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::<Vec<_>>(),
"types": type_rows.iter().map(|r| serde_json::json!({"name": r.name, "count": r.count})).collect::<Vec<_>>(),
});
tracing::info!(
tool = "secrets_overview",
?user_id,
total,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
}
// ── ServerHandler ─────────────────────────────────────────────────────────────
@@ -847,11 +1157,11 @@ impl ServerHandler for SecretsService {
info.protocol_version = ProtocolVersion::V_2025_06_18;
info.instructions = Some(
"Manage cross-device secrets and configuration securely. \
Data is encrypted with your passphrase-derived key. \
Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \
Use secrets_search to discover entries (Bearer token required; encryption key not needed), \
secrets_get to decrypt secret values, \
and secrets_add/secrets_update to write encrypted secrets."
Use secrets_find to discover entries by folder, name, type, tags, or query \
(query also searches metadata values). \
Use secrets_get with the entry id (from secrets_find) to decrypt secret values. \
Use secrets_add / secrets_update to write entries. \
Use secrets_overview for a quick count of entries per folder and type."
.to_string(),
);
info

View File

@@ -8,9 +8,10 @@ use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
routing::{get, patch, post},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_sessions::Session;
use uuid::Uuid;
@@ -19,6 +20,9 @@ use secrets_core::crypto::hex;
use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key},
audit_log::list_for_user,
delete::delete_by_id,
search::{SearchParams, count_entries, fetch_secret_schemas, list_entries},
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
user::{
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
unbind_oauth_account, update_user_key_setup,
@@ -39,6 +43,15 @@ const SESSION_LOGIN_PROVIDER: &str = "login_provider";
#[template(path = "login.html")]
struct LoginTemplate {
has_google: bool,
base_url: String,
version: &'static str,
}
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate {
is_logged_in: bool,
base_url: String,
version: &'static str,
}
@@ -69,6 +82,51 @@ struct AuditEntryView {
detail: String,
}
#[derive(Template)]
#[template(path = "entries.html")]
struct EntriesPageTemplate {
user_name: String,
user_email: String,
entries: Vec<EntryListItemView>,
total_count: i64,
shown_count: usize,
limit: u32,
filter_folder: String,
filter_type: String,
version: &'static str,
}
/// Non-sensitive fields only (no `secrets` / ciphertext).
struct EntryListItemView {
id: String,
folder: String,
entry_type: String,
name: String,
notes: String,
tags: String,
metadata: String,
secrets: Vec<SecretSummaryView>,
/// RFC3339 UTC for `<time datetime>`; localized in entries.html.
updated_at_iso: String,
}
struct SecretSummaryView {
id: String,
name: String,
secret_type: String,
}
/// Cap for HTML list (avoids loading unbounded rows into memory).
const ENTRIES_PAGE_LIMIT: u32 = 5_000;
#[derive(Deserialize)]
struct EntriesQuery {
folder: Option<String>,
/// URL query key is `type` (maps to DB column `entries.type`).
#[serde(rename = "type")]
entry_type: Option<String>,
}
// ── App state helpers ─────────────────────────────────────────────────────────
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
@@ -134,11 +192,13 @@ pub fn web_router() -> Router<AppState> {
"/.well-known/oauth-protected-resource",
get(oauth_protected_resource_metadata),
)
.route("/", get(login_page))
.route("/", get(home_page))
.route("/login", get(login_page))
.route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback))
.route("/auth/logout", post(auth_logout))
.route("/dashboard", get(dashboard))
.route("/entries", get(entries_page))
.route("/audit", get(audit_page))
.route("/account/bind/google", get(account_bind_google))
.route(
@@ -150,6 +210,14 @@ pub fn web_router() -> Router<AppState> {
.route("/api/key-setup", post(api_key_setup))
.route("/api/apikey", get(api_apikey_get))
.route("/api/apikey/regenerate", post(api_apikey_regenerate))
.route(
"/api/entries/{id}",
patch(api_entry_patch).delete(api_entry_delete),
)
.route(
"/api/entries/{entry_id}/secrets/{secret_id}",
axum::routing::delete(api_entry_secret_unlink),
)
}
fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
@@ -188,6 +256,21 @@ async fn favicon_svg() -> Response {
.expect("favicon response")
}
// ── Home page (public) ───────────────────────────────────────────────────────
async fn home_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let is_logged_in = current_user_id(&session).await.is_some();
let tmpl = HomeTemplate {
is_logged_in,
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Login page ────────────────────────────────────────────────────────────────
async fn login_page(
@@ -200,6 +283,7 @@ async fn login_page(
let tmpl = LoginTemplate {
has_google: state.google_config.is_some(),
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
@@ -282,16 +366,16 @@ where
{
if let Some(err) = params.error {
tracing::warn!(provider, error = %err, "OAuth error");
return Ok(Redirect::to("/?error=oauth_error").into_response());
return Ok(Redirect::to("/login?error=oauth_error").into_response());
}
let Some(code) = params.code else {
tracing::warn!(provider, "OAuth callback missing code");
return Ok(Redirect::to("/?error=oauth_missing_code").into_response());
return Ok(Redirect::to("/login?error=oauth_missing_code").into_response());
};
let Some(returned_state) = params.state.as_deref() else {
tracing::warn!(provider, "OAuth callback missing state");
return Ok(Redirect::to("/?error=oauth_missing_state").into_response());
return Ok(Redirect::to("/login?error=oauth_missing_state").into_response());
};
let expected_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.map_err(|e| {
@@ -304,7 +388,7 @@ where
expected_present = expected_state.is_some(),
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
);
return Ok(Redirect::to("/?error=oauth_state").into_response());
return Ok(Redirect::to("/login?error=oauth_state").into_response());
}
if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
tracing::warn!(provider, error = %e, "failed to remove oauth_state from session");
@@ -430,7 +514,7 @@ async fn dashboard(
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
return Ok(Redirect::to("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
@@ -438,7 +522,7 @@ async fn dashboard(
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
None => return Ok(Redirect::to("/login").into_response()),
};
let tmpl = DashboardTemplate {
@@ -452,12 +536,115 @@ async fn dashboard(
render_template(tmpl)
}
async fn entries_page(
State(state): State<AppState>,
session: Session,
Query(q): Query<EntriesQuery>,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let folder_filter = q
.folder
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let type_filter = q
.entry_type
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let params = SearchParams {
folder: folder_filter.as_deref(),
entry_type: type_filter.as_deref(),
name: None,
tags: &[],
query: None,
sort: "updated",
limit: ENTRIES_PAGE_LIMIT,
offset: 0,
user_id: Some(user_id),
};
let total_count = count_entries(&state.pool, &params).await.map_err(|e| {
tracing::error!(error = %e, "failed to count entries for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let rows = list_entries(&state.pool, params).await.map_err(|e| {
tracing::error!(error = %e, "failed to load entries list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let shown_count = rows.len();
let entry_ids: Vec<Uuid> = rows.iter().map(|e| e.id).collect();
let secret_schemas = fetch_secret_schemas(&state.pool, &entry_ids)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load secret schema list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|e| EntryListItemView {
id: e.id.to_string(),
folder: e.folder,
entry_type: e.entry_type,
name: e.name,
notes: e.notes,
tags: e.tags.join(", "),
metadata: serde_json::to_string_pretty(&e.metadata)
.unwrap_or_else(|_| "{}".to_string()),
secrets: secret_schemas
.get(&e.id)
.map(|fields| {
fields
.iter()
.map(|f| SecretSummaryView {
id: f.id.to_string(),
name: f.name.clone(),
secret_type: f.secret_type.clone(),
})
.collect()
})
.unwrap_or_default(),
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
})
.collect();
let tmpl = EntriesPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
total_count,
shown_count,
limit: ENTRIES_PAGE_LIMIT,
filter_folder: folder_filter.unwrap_or_default(),
filter_type: type_filter.unwrap_or_default(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
async fn audit_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
return Ok(Redirect::to("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
@@ -465,7 +652,7 @@ async fn audit_page(
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
None => return Ok(Redirect::to("/login").into_response()),
};
let rows = list_for_user(&state.pool, user_id, 100)
@@ -480,7 +667,7 @@ async fn audit_page(
.map(|row| AuditEntryView {
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
action: row.action,
target: format_audit_target(&row.namespace, &row.kind, &row.name),
target: format_audit_target(&row.folder, &row.entry_type, &row.name),
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
})
.collect();
@@ -725,6 +912,223 @@ async fn api_apikey_regenerate(
Ok(Json(ApiKeyResponse { api_key }))
}
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
#[derive(Deserialize)]
struct EntryPatchBody {
folder: String,
#[serde(rename = "type")]
entry_type: String,
name: String,
notes: String,
tags: Vec<String>,
metadata: serde_json::Value,
}
type EntryApiError = (StatusCode, Json<serde_json::Value>);
fn map_entry_mutation_err(e: anyhow::Error) -> EntryApiError {
let msg = e.to_string();
if msg.contains("Entry not found") {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "条目不存在或无权访问" })),
);
}
if msg.contains("already exists") {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "该账号下已存在相同 folder + name 的条目" })),
);
}
if msg.contains("Concurrent modification") {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "条目已被修改,请刷新后重试" })),
);
}
if msg.contains("must be at most") {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg })));
}
tracing::error!(error = %e, "entry mutation failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "操作失败,请稍后重试" })),
)
}
async fn api_entry_patch(
State(state): State<AppState>,
session: Session,
Path(entry_id): Path<Uuid>,
Json(body): Json<EntryPatchBody>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let user_id = current_user_id(&session)
.await
.ok_or((StatusCode::UNAUTHORIZED, Json(json!({ "error": "未登录" }))))?;
let folder = body.folder.trim();
let entry_type = body.entry_type.trim();
let name = body.name.trim();
let notes = body.notes.trim();
if name.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "name 不能为空" })),
));
}
let tags: Vec<String> = body
.tags
.into_iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if !body.metadata.is_object() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "metadata 必须是 JSON 对象" })),
));
}
update_fields_by_id(
&state.pool,
entry_id,
user_id,
UpdateEntryFieldsByIdParams {
folder,
entry_type,
name,
notes,
tags: &tags,
metadata: &body.metadata,
},
)
.await
.map_err(map_entry_mutation_err)?;
Ok(Json(json!({ "ok": true })))
}
async fn api_entry_delete(
State(state): State<AppState>,
session: Session,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let user_id = current_user_id(&session)
.await
.ok_or((StatusCode::UNAUTHORIZED, Json(json!({ "error": "未登录" }))))?;
let result = delete_by_id(&state.pool, entry_id, user_id)
.await
.map_err(map_entry_mutation_err)?;
Ok(Json(json!({
"ok": true,
"migrated": result.migrated,
})))
}
async fn api_entry_secret_unlink(
State(state): State<AppState>,
session: Session,
Path((entry_id, secret_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
#[derive(sqlx::FromRow)]
struct EntryAuditRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
let user_id = current_user_id(&session)
.await
.ok_or((StatusCode::UNAUTHORIZED, Json(json!({ "error": "未登录" }))))?;
let mut tx = state
.pool
.begin()
.await
.map_err(|e| map_entry_mutation_err(e.into()))?;
let entry_row: Option<EntryAuditRow> =
sqlx::query_as("SELECT folder, type, name FROM entries WHERE id = $1 AND user_id = $2")
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into()))?;
let Some(entry_row) = entry_row else {
tx.rollback()
.await
.map_err(|e| map_entry_mutation_err(e.into()))?;
return Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": "条目不存在或无权访问" })),
));
};
let deleted = sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(entry_id)
.bind(secret_id)
.execute(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into()))?
.rows_affected();
if deleted == 0 {
tx.rollback()
.await
.map_err(|e| map_entry_mutation_err(e.into()))?;
return Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": "关联不存在" })),
));
}
let secret_deleted = 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(secret_id)
.execute(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into()))?
.rows_affected()
> 0;
secrets_core::audit::log_tx(
&mut tx,
Some(user_id),
"unlink_secret",
&entry_row.folder,
&entry_row.entry_type,
&entry_row.name,
json!({
"source": "web",
"entry_id": entry_id,
"secret_id": secret_id,
"deleted_secret": secret_deleted,
}),
)
.await;
tx.commit()
.await
.map_err(|e| map_entry_mutation_err(e.into()))?;
Ok(Json(json!({
"ok": true,
"deleted_relation": true,
"deleted_secret": secret_deleted,
})))
}
// ── OAuth / Well-known ────────────────────────────────────────────────────────
/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
@@ -757,11 +1161,15 @@ fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
Ok(Html(html).into_response())
}
fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String {
// Auth events reuse kind/name as a provider-scoped target, not an entry identity.
if namespace == "auth" {
format!("{}/{}", kind, name)
fn format_audit_target(folder: &str, entry_type: &str, name: &str) -> String {
// Auth events (folder="auth") use entry_type/name as provider-scoped target.
if folder == "auth" {
format!("{}/{}", entry_type, name)
} else if !folder.is_empty() && !entry_type.is_empty() {
format!("[{}/{}] {}", folder, entry_type, name)
} else if !folder.is_empty() {
format!("[{}] {}", folder, name)
} else {
format!("[{}/{}] {}", namespace, kind, name)
name.to_string()
}
}

View File

@@ -2,10 +2,15 @@
> 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**Streamable HTTP **MCP**Model Context Protocol与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth如已配置登录 WebMCP 调用使用 API Key 与加密相关请求头。
## 公开页面
- **`/`**:公开首页,说明安全架构(客户端密钥派生、密文存储、多租户与审计等),无需登录。
## 不应抓取或索引的内容
- **`/mcp`**MCP 流式 HTTP 端点JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。
- **`/api/*`**:会话或 API Key 相关的 HTTP API。
- **`/login`**:登录入口页(`noindex` / robots 通常 disallow
- **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。
## 给 AI 助手的实用提示
@@ -16,7 +21,7 @@
## 延伸阅读
- 开源仓库中的 `README.md`、`AGENTS.md`(若可访问)包含环境变量、表结构与运维约定。
- 源码仓库:<https://gitea.refining.dev/refining/secrets>`README.md`、`AGENTS.md` 含环境变量、表结构与运维约定
## 关于本文件

View File

@@ -8,8 +8,11 @@ Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/
# 首页 `/` 为公开安全说明页,允许抓取。
# 面向 AI / LLM 的机器可读站点说明Markdown/llms.txt
# Human & AI-readable site summary: /llms.txt (also /ai.txt)
@@ -24,4 +27,5 @@ Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/

View File

@@ -92,6 +92,7 @@
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/entries" class="sidebar-link">条目</a>
<a href="/audit" class="sidebar-link active">审计</a>
</nav>
</aside>

View File

@@ -174,6 +174,7 @@
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link active">MCP</a>
<a href="/entries" class="sidebar-link">条目</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>

View File

@@ -0,0 +1,490 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — 条目</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1480px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px;
}
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.filter-field input {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
}
.filter-field input:focus { border-color: var(--accent); }
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.btn-filter {
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-filter:hover { background: var(--accent-hover); }
.btn-clear {
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { background: var(--surface2); color: var(--text); }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
.table-wrap {
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
max-height: 72vh;
}
table {
width: max-content;
min-width: 1240px;
border-collapse: separate;
border-spacing: 0;
}
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 2;
background: var(--surface);
}
td { font-size: 13px; line-height: 1.45; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; }
.col-updated { min-width: 168px; }
.col-folder { min-width: 128px; }
.col-type { min-width: 108px; }
.col-name { min-width: 180px; max-width: 260px; }
.col-tags { min-width: 160px; max-width: 220px; }
.col-actions { min-width: 132px; }
.cell-name, .cell-tags-val {
overflow-wrap: anywhere;
word-break: break-word;
}
.cell-notes, .cell-meta { min-width: 260px; max-width: 360px; }
.notes-scroll {
max-height: 120px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 360px; max-height: 120px; overflow: auto;
}
.col-actions { white-space: nowrap; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.col-secrets { min-width: 300px; max-width: 420px; }
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
.secret-chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
background: var(--surface2);
font-family: 'JetBrains Mono', monospace;
max-width: 100%;
min-width: 0;
}
.secret-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secret-type {
color: var(--text-muted);
border-left: 1px solid var(--border);
padding-left: 6px;
}
.btn-unlink-secret {
border: none;
background: transparent;
color: #f85149;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
}
.btn-row {
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-overlay[hidden] { display: none !important; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.modal-field { margin-bottom: 12px; }
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.modal-field input, .modal-field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none;
}
.modal-field textarea { min-height: 72px; resize: vertical; }
.modal-field textarea.metadata-edit { min-height: 140px; }
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
.modal-error.visible { display: block; }
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
.btn-modal.primary:hover { background: var(--accent-hover); }
.btn-modal.danger { border-color: #f85149; color: #f85149; }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.table-wrap { max-height: none; border: none; background: transparent; }
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; max-width: none; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
}
td.col-updated::before { content: "更新"; }
td.col-folder::before { content: "Folder"; }
td.col-type::before { content: "Type"; }
td.col-name::before { content: "Name"; }
td.col-notes::before { content: "Notes"; }
td.col-tags::before { content: "Tags"; }
td.col-meta::before { content: "Metadata"; }
td.col-secrets::before { content: "Secrets"; }
td.col-actions::before { content: "操作"; }
.detail, .notes-scroll, .secret-list { max-width: none; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/entries" class="sidebar-link active">条目</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title">我的条目</div>
<div class="card-subtitle">在当前筛选条件下,共 <strong>{{ total_count }}</strong> 条记录;本页显示 <strong>{{ shown_count }}</strong> 条(按更新时间降序,单页最多 {{ limit }} 条)。不含密文字段。时间为浏览器本地时区。提示:非敏感地址类字段(如 address / endpoint / url建议放在 Metadata例如 <code>metadata.address</code>),仅密码/令牌等放 Secrets。</div>
<form class="filter-bar" method="get" action="/entries">
<div class="filter-field">
<label for="filter-folder">Folder精确匹配</label>
<input id="filter-folder" name="folder" type="text" value="{{ filter_folder }}" placeholder="例如 refining" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-type">Type精确匹配</label>
<input id="filter-type" name="type" type="text" value="{{ filter_type }}" placeholder="例如 server" autocomplete="off">
</div>
<div class="filter-actions">
<button type="submit" class="btn-filter">筛选</button>
<a href="/entries" class="btn-clear">清空</a>
</div>
</form>
{% if entries.is_empty() %}
<div class="empty">暂无条目。</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>更新</th>
<th>Folder</th>
<th>Type</th>
<th>Name</th>
<th>Notes</th>
<th>Tags</th>
<th>Metadata</th>
<th>Secrets</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}">
<td class="col-updated mono"><time class="entry-local-time" datetime="{{ entry.updated_at_iso }}">{{ entry.updated_at_iso }}</time></td>
<td class="col-folder mono cell-folder">{{ entry.folder }}</td>
<td class="col-type mono cell-type">{{ entry.entry_type }}</td>
<td class="col-name mono cell-name">{{ entry.name }}</td>
<td class="col-notes cell-notes">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val">{{ entry.tags }}</td>
<td class="col-meta cell-meta"><pre class="detail cell-meta-val">{{ entry.metadata }}</pre></td>
<td class="col-secrets">
<div class="secret-list">
{% for s in entry.secrets %}
<span class="secret-chip">
<span class="secret-name" title="{{ s.name }}">{{ s.name }}</span>
<span class="secret-type">{{ s.secret_type }}</span>
<button type="button" class="btn-unlink-secret" data-secret-id="{{ s.id }}" data-secret-name="{{ s.name }}" title="解除关联">x</button>
</span>
{% endfor %}
</div>
</td>
<td class="col-actions">
<div class="row-actions">
<button type="button" class="btn-row btn-edit">编辑</button>
<button type="button" class="btn-row danger btn-del">删除</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
</main>
</div>
</div>
<div id="edit-overlay" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<div class="modal-title" id="edit-title">编辑条目</div>
<div id="edit-error" class="modal-error"></div>
<div class="modal-field"><label for="edit-folder">Folder</label><input id="edit-folder" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-type">Type</label><input id="edit-type" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-name">Name</label><input id="edit-name" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-notes">Notes</label><textarea id="edit-notes"></textarea></div>
<div class="modal-field"><label for="edit-tags">Tags逗号分隔</label><input id="edit-tags" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-metadata">MetadataJSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="edit-cancel">取消</button>
<button type="button" class="btn-modal primary" id="edit-save">保存</button>
</div>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.entry-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
var editOverlay = document.getElementById('edit-overlay');
var editError = document.getElementById('edit-error');
var editFolder = document.getElementById('edit-folder');
var editType = document.getElementById('edit-type');
var editName = document.getElementById('edit-name');
var editNotes = document.getElementById('edit-notes');
var editTags = document.getElementById('edit-tags');
var editMetadata = document.getElementById('edit-metadata');
var currentEntryId = null;
function showEditErr(msg) {
editError.textContent = msg || '';
editError.classList.toggle('visible', !!msg);
}
function openEdit(tr) {
var id = tr.getAttribute('data-entry-id');
if (!id) return;
currentEntryId = id;
showEditErr('');
editFolder.value = tr.querySelector('.cell-folder') ? tr.querySelector('.cell-folder').textContent.trim() : '';
editType.value = tr.querySelector('.cell-type') ? tr.querySelector('.cell-type').textContent.trim() : '';
editName.value = tr.querySelector('.cell-name') ? tr.querySelector('.cell-name').textContent.trim() : '';
editNotes.value = tr.querySelector('.cell-notes-val') ? tr.querySelector('.cell-notes-val').textContent : '';
var tagsText = tr.querySelector('.cell-tags-val') ? tr.querySelector('.cell-tags-val').textContent.trim() : '';
editTags.value = tagsText;
var metaPre = tr.querySelector('.cell-meta-val');
editMetadata.value = metaPre ? metaPre.textContent : '{}';
editOverlay.hidden = false;
}
function closeEdit() {
editOverlay.hidden = true;
currentEntryId = null;
showEditErr('');
}
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
editOverlay.addEventListener('click', function (e) {
if (e.target === editOverlay) closeEdit();
});
document.getElementById('edit-save').addEventListener('click', function () {
if (!currentEntryId) return;
var meta;
try {
meta = JSON.parse(editMetadata.value);
} catch (err) {
showEditErr('Metadata 不是合法 JSON');
return;
}
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
showEditErr('Metadata 必须是 JSON 对象');
return;
}
var tags = editTags.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var body = {
folder: editFolder.value,
type: editType.value,
name: editName.value.trim(),
notes: editNotes.value,
tags: tags,
metadata: meta
};
showEditErr('');
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
closeEdit();
window.location.reload();
}).catch(function (e) {
showEditErr(e.message || String(e));
});
});
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
tr.querySelector('.btn-del').addEventListener('click', function () {
var id = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var name = nameEl ? nameEl.textContent.trim() : '';
if (!id) return;
if (!confirm('确定删除条目「' + name + '」?')) return;
fetch('/api/entries/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
})
.then(function (data) {
if (data && Array.isArray(data.migrated) && data.migrated.length > 0) {
alert('已自动迁移共享 key 引用:' + data.migrated.length + ' 个条目完成重定向。');
}
window.location.reload();
})
.catch(function (e) { alert(e.message || String(e)); });
});
tr.querySelectorAll('.btn-unlink-secret').forEach(function (btn) {
btn.addEventListener('click', function () {
var entryId = tr.getAttribute('data-entry-id');
var secretId = btn.getAttribute('data-secret-id');
var secretName = btn.getAttribute('data-secret-name') || '';
if (!entryId || !secretId) return;
if (!confirm('确定解除 secret 关联「' + secretName + '」?')) return;
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE',
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
window.location.reload();
}).catch(function (e) {
alert(e.message || String(e));
});
});
});
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Secrets MCP基于 Model Context Protocol 的密钥与配置管理。密码短语在浏览器本地 PBKDF2 派生,密文 AES-GCM 存储,完整审计与历史版本。">
<meta name="keywords" content="secrets management,MCP,Model Context Protocol,end-to-end encryption,AES-GCM,PBKDF2,API key,密钥管理">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ base_url }}/">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets MCP — 端到端加密的密钥管理</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/">
<meta property="og:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta property="og:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta name="twitter:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #79b8ff;
}
html, body { height: 100%; overflow: hidden; }
@supports (height: 100dvh) {
html, body { height: 100dvh; }
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
display: flex;
flex-direction: column;
}
.nav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 600;
color: var(--text);
text-decoration: none;
}
.brand span { color: var(--accent); }
.nav-right { display: flex; align-items: center; gap: 14px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
.lang-btn {
padding: 4px 10px; 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); }
.cta {
display: inline-flex; align-items: center; justify-content: center;
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
text-decoration: none; border: 1px solid var(--accent);
background: rgba(88, 166, 255, 0.12); color: var(--accent);
transition: background 0.15s, color 0.15s;
}
.cta:hover { background: var(--accent); color: var(--bg); }
.main {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 24px 12px;
gap: 20px;
}
.hero { text-align: center; max-width: 720px; }
.hero h1 { font-size: clamp(20px, 4vw, 28px); font-weight: 600; margin-bottom: 8px; line-height: 1.25; }
.hero .tagline { color: var(--text-muted); font-size: clamp(13px, 2vw, 15px); line-height: 1.5; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
max-width: 900px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; gap: 8px; }
.main { justify-content: flex-start; padding-top: 12px; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 14px 12px;
min-height: 0;
}
.card-icon {
width: 32px; height: 32px; border-radius: 8px;
background: var(--surface2);
display: flex; align-items: center; justify-content: center;
margin-bottom: 10px; color: var(--accent);
}
.card-icon svg { width: 18px; height: 18px; }
.card h2 { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
.card p { font-size: 12px; color: var(--text-muted); line-height: 1.45; }
.foot {
flex-shrink: 0;
text-align: center;
padding: 8px 16px 12px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: var(--surface);
}
.foot a { color: var(--accent); text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">secrets<span>-mcp</span></a>
<div class="nav-right">
<div class="lang-bar">
<button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div>
{% if is_logged_in %}
<a class="cta" href="/dashboard" data-i18n="ctaDashboard">进入控制台</a>
{% else %}
<a class="cta" href="/login" data-i18n="ctaLogin">登录</a>
{% endif %}
</div>
</header>
<main class="main">
<div class="hero">
<h1 data-i18n="heroTitle">端到端加密的密钥与配置管理</h1>
<p class="tagline" data-i18n="heroTagline">Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。</p>
</div>
<div class="grid">
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 11c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v3c0 1.66 1.34 3 3 3z"/><path d="M19 10v1a7 7 0 01-14 0v-1"/><path d="M12 14v7M9 18h6"/></svg>
</div>
<h2 data-i18n="c1t">客户端密钥派生</h2>
<p data-i18n="c1d">PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<h2 data-i18n="c2t">AES-256-GCM 加密</h2>
<p data-i18n="c2d">敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
</div>
<h2 data-i18n="c3t">审计与历史</h2>
<p data-i18n="c3d">操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。</p>
</article>
</div>
</main>
<footer class="foot">
<span data-i18n="versionLabel">版本</span> {{ version }} ·
<a href="/llms.txt">llms.txt</a>
<span data-i18n="sep"> · </span>
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
{% if !is_logged_in %}
<span data-i18n="sep"> · </span>
<a href="/login" data-i18n="footLogin">登录</a>
{% endif %}
</footer>
<script>
const T = {
'zh-CN': {
docTitle: 'Secrets MCP — 端到端加密的密钥管理',
ctaDashboard: '进入控制台',
ctaLogin: '登录',
heroTitle: '端到端加密的密钥与配置管理',
heroTagline: 'Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。',
c1t: '客户端密钥派生',
c1d: 'PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。',
c3t: '审计与历史',
c3d: '操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。',
versionLabel: '版本',
sep: ' · ',
footRepo: '源码仓库',
footLogin: '登录',
},
'zh-TW': {
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
ctaDashboard: '進入控制台',
ctaLogin: '登入',
heroTitle: '端到端加密的金鑰與設定管理',
heroTagline: 'Streamable HTTP MCP 與 Web 控制台:中繼資料與密文分庫儲存,金鑰不離開你的用戶端邏輯。',
c1t: '用戶端金鑰派生',
c1d: 'PBKDF2-SHA256約 60 萬次)在瀏覽器本地從密碼片語派生金鑰;伺服端僅保存鹽與校驗值,不持有密碼或明文主金鑰。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感欄位以 AES-GCM 密文落庫Web 端在本地加解密,明文預設不經伺服端持久化。',
c3t: '稽核與歷史',
c3d: '操作寫入稽核日誌;條目與密文保留歷史版本,支援依版本檢視與還原。',
versionLabel: '版本',
sep: ' · ',
footRepo: '原始碼倉庫',
footLogin: '登入',
},
'en': {
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
ctaDashboard: 'Open dashboard',
ctaLogin: 'Sign in',
heroTitle: 'End-to-end encrypted secrets and configuration',
heroTagline: 'Streamable HTTP MCP plus web console: metadata and ciphertext stored separately; keys stay on your client.',
c1t: 'Client-side key derivation',
c1d: 'PBKDF2-SHA256 (~600k iterations) derives keys from your passphrase in the browser; the server stores only salt and a verification blob, never your password or raw master key.',
c2t: 'AES-256-GCM',
c2d: 'Secret fields are stored as AES-GCM ciphertext; the web UI encrypts and decrypts locally so plaintext is not persisted server-side by default.',
c3t: 'Audit and history',
c3d: 'Operations are audited; entries and secrets keep version history for review and rollback.',
versionLabel: 'Version',
sep: ' · ',
footRepo: 'Source repository',
footLogin: 'Sign in',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) {
return (T[currentLang] && T[currentLang][key]) || T['en'][key] || key;
}
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

View File

@@ -3,8 +3,19 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, follow">
<meta name="description" content="登录 Secrets MCP Web 控制台,安全管理跨设备加密 secrets。">
<meta name="keywords" content="Secrets MCP,登录,OAuth,密钥管理">
<link rel="canonical" href="{{ base_url }}/login">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Sign In</title>
<title>登录 — Secrets MCP</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/login">
<meta property="og:title" content="登录 — Secrets MCP">
<meta property="og:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="登录 — Secrets MCP">
<meta name="twitter:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
@@ -17,6 +28,7 @@
--accent: #58a6ff;
--accent-hover: #79b8ff;
--google: #4285f4;
--danger: #f85149;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
@@ -25,11 +37,24 @@
padding: 48px 40px; width: 100%; max-width: 400px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.topbar { display: flex; justify-content: flex-end; margin-bottom: 20px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
.topbar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 12px; }
.back-home {
font-size: 13px; color: var(--accent); text-decoration: none; white-space: nowrap;
}
.back-home:hover { text-decoration: underline; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; flex-shrink: 0; }
.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); }
.oauth-alert {
display: none;
margin-bottom: 16px; padding: 10px 12px; border-radius: 8px;
font-size: 13px; line-height: 1.4;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.35);
color: #ffa198;
}
.oauth-alert.visible { display: block; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn {
@@ -48,12 +73,14 @@
<body>
<div class="card">
<div class="topbar">
<a class="back-home" href="/" data-i18n="backHome">返回首页</a>
<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>
<button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
<div id="oauth-alert" class="oauth-alert" role="alert"></div>
<h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>
@@ -78,22 +105,40 @@
<script>
const T = {
'zh-CN': {
docTitle: '登录 — Secrets MCP',
backHome: '返回首页',
title: '登录',
subtitle: '安全管理你的跨设备 secrets。',
google: '使用 Google 登录',
noProviders: '未配置登录方式,请联系管理员。',
err_oauth_error: '登录失败:授权提供方返回错误,请重试。',
err_oauth_missing_code: '登录失败:未收到授权码,请重试。',
err_oauth_missing_state: '登录失败:缺少安全校验参数,请重试。',
err_oauth_state: '登录失败:会话校验不匹配(可能因 Cookie 策略或服务器重启)。请返回首页再试。',
},
'zh-TW': {
docTitle: '登入 — Secrets MCP',
backHome: '返回首頁',
title: '登入',
subtitle: '安全管理你的跨裝置 secrets。',
google: '使用 Google 登入',
noProviders: '尚未設定登入方式,請聯絡管理員。',
err_oauth_error: '登入失敗:授權方回傳錯誤,請再試一次。',
err_oauth_missing_code: '登入失敗:未取得授權碼,請再試一次。',
err_oauth_missing_state: '登入失敗:缺少安全校驗參數,請再試一次。',
err_oauth_state: '登入失敗:工作階段校驗不符(可能與 Cookie 政策或伺服器重啟有關)。請回到首頁再試。',
},
'en': {
docTitle: 'Sign in — Secrets MCP',
backHome: 'Back to home',
title: 'Sign in',
subtitle: 'Manage your cross-device secrets securely.',
google: 'Continue with Google',
noProviders: 'No login providers configured. Please contact your administrator.',
err_oauth_error: 'Sign-in failed: the identity provider returned an error. Please try again.',
err_oauth_missing_code: 'Sign-in failed: no authorization code was returned. Please try again.',
err_oauth_missing_state: 'Sign-in failed: missing security state. Please try again.',
err_oauth_state: 'Sign-in failed: session state mismatch (often cookies or server restart). Open the home page and try again.',
}
};
@@ -101,8 +146,23 @@
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function showOAuthError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('error');
const el = document.getElementById('oauth-alert');
if (!code || !code.startsWith('oauth_')) {
el.classList.remove('visible');
el.textContent = '';
return;
}
const key = 'err_' + code;
el.textContent = t(key) || t('err_oauth_error');
el.classList.add('visible');
}
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
@@ -111,6 +171,7 @@
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
showOAuthError();
}
function setLang(lang) {

View File

@@ -3,7 +3,13 @@
# ─── 数据库 ───────────────────────────────────────────────────────────
# Web 会话tower-sessions与业务数据共用此库启动时会自动 migrate 会话表,无需额外环境变量。
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-mcp
# 强烈建议生产使用 verify-full至少 verify-ca
SECRETS_DATABASE_SSL_MODE=verify-full
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
# SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
# 当设为 prod/production 时,服务会拒绝弱 TLS 模式prefer/disable/allow/require
SECRETS_ENV=production
# ─── 服务地址 ─────────────────────────────────────────────────────────
# 内网监听地址Cloudflare / Nginx 反代时填内网端口)
@@ -22,6 +28,9 @@ GOOGLE_CLIENT_SECRET=
# WECHAT_APP_CLIENT_ID=
# WECHAT_APP_CLIENT_SECRET=
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_mcp=debug
# ─── 注意 ─────────────────────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。

View File

@@ -0,0 +1,92 @@
# PostgreSQL TLS Hardening Runbook
This runbook applies to:
- PostgreSQL server: `47.117.131.22` (`db.refining.ltd`)
- `secrets-mcp` app server: `47.238.146.244` (`secrets.refining.app`)
## 1) Issue certificate for `db.refining.ltd` (Let's Encrypt + Cloudflare DNS-01)
Install `acme.sh` on the PostgreSQL server and use a Cloudflare API token with DNS edit permission for the target zone.
```bash
curl https://get.acme.sh | sh -s email=ops@refining.ltd
export CF_Token="your_cloudflare_dns_token"
export CF_Zone_ID="your_zone_id"
~/.acme.sh/acme.sh --issue --dns dns_cf -d db.refining.ltd --keylength ec-256
```
Install cert/key into a PostgreSQL-readable path:
```bash
sudo mkdir -p /etc/postgresql/tls
sudo ~/.acme.sh/acme.sh --install-cert -d db.refining.ltd --ecc \
--fullchain-file /etc/postgresql/tls/fullchain.pem \
--key-file /etc/postgresql/tls/privkey.pem \
--reloadcmd "systemctl reload postgresql || systemctl restart postgresql"
sudo chown -R postgres:postgres /etc/postgresql/tls
sudo chmod 600 /etc/postgresql/tls/privkey.pem
sudo chmod 644 /etc/postgresql/tls/fullchain.pem
```
## 2) Configure PostgreSQL TLS and access rules
In `postgresql.conf`:
```conf
ssl = on
ssl_cert_file = '/etc/postgresql/tls/fullchain.pem'
ssl_key_file = '/etc/postgresql/tls/privkey.pem'
```
In `pg_hba.conf`, allow app traffic via TLS only (example):
```conf
hostssl secrets-mcp postgres 47.238.146.244/32 scram-sha-256
```
Keep a safe admin path (`local` socket or restricted source CIDR) before removing old plaintext `host` rules.
Reload PostgreSQL:
```bash
sudo systemctl reload postgresql
```
## 3) Verify server-side TLS
```bash
openssl s_client -starttls postgres -connect db.refining.ltd:5432 -servername db.refining.ltd
```
The handshake should succeed and the certificate should match `db.refining.ltd`.
## 4) Update `secrets-mcp` app server env
Use environment values like:
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_ENV=production
```
If you use private CA instead of public CA, also set:
```bash
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
```
Restart `secrets-mcp` after updating env.
## 5) Verify from app server
Run positive and negative checks:
- Positive: app starts, migrations pass, dashboard + MCP API work.
- Negative:
- wrong hostname -> connection fails
- wrong CA file -> connection fails
- disable TLS on DB -> connection fails
This ensures no silent downgrade to weak TLS in production.

View File

@@ -0,0 +1,126 @@
-- 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

@@ -0,0 +1,67 @@
-- 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

@@ -0,0 +1,81 @@
#!/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"

194
scripts/migrate-v0.3.0.sql Normal file
View File

@@ -0,0 +1,194 @@
-- ============================================================================
-- 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;