feat(vNext.4): implement trusted-subject security chain for portal user key self-service
- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved
This implements the secure chain:
Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)
Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services
Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
This commit is contained in:
412
docs/2026-06-08-SYSTEMATIC_REVIEW_REPORT.md
Normal file
412
docs/2026-06-08-SYSTEMATIC_REVIEW_REPORT.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# sub2api-cn-relay-manager 系统化全面 Review 报告
|
||||
|
||||
日期:2026-06-08
|
||||
审查范围:`cmd/`、`internal/`、`tests/integration/`、`scripts/`、`deploy/`、`Dockerfile*`、`.github/workflows/ci.yml`、`README.md`、`docs/` 中与运行/验收/真相直接相关文档
|
||||
审查方式:静态源码审查 + 文档/脚本一致性核对 + 本地质量门禁执行
|
||||
|
||||
---
|
||||
|
||||
## 一、执行结论
|
||||
|
||||
结论:**不建议按“严格生产级通过”评价当前代码库**。
|
||||
原因不是基础质量差,而是存在几类会直接影响认证边界、网关语义和 key 治理正确性的系统性问题。
|
||||
|
||||
本次审查同时确认:
|
||||
|
||||
- 当前项目的 **本地质量门禁是通过的**:`bash ./scripts/test/verify_quality_gates.sh` 全部 PASS。
|
||||
- 代码库在测试覆盖、SQLite repo 测试密度、真实验收 artifact、前端回归脚本方面,已经具备较强工程化基础。
|
||||
- 但以下问题仍然属于**实现语义级缺陷**,不会被现有门禁完全拦住。
|
||||
|
||||
---
|
||||
|
||||
## 二、已执行验证
|
||||
|
||||
已实际执行:
|
||||
|
||||
```bash
|
||||
bash ./scripts/test/verify_quality_gates.sh
|
||||
```
|
||||
|
||||
观察结果:
|
||||
|
||||
- `test_tksea_portal_assets.sh`:PASS
|
||||
- `verify_frontend_smoke.sh`:PASS
|
||||
- `verify_vnext_slo_release_gate.sh`:PASS
|
||||
- `gofmt -l .`:PASS
|
||||
- `go vet ./...`:PASS
|
||||
- `go test -cover ./internal/...`:PASS
|
||||
- `go test ./tests/integration/... -count=1`:PASS
|
||||
- 核心覆盖率:
|
||||
- `internal/access` 84.0%
|
||||
- `internal/app` 70.1%
|
||||
- `internal/provision` 80.8%
|
||||
- `internal/store/sqlite` 77.6%
|
||||
- `internal/pack` 75.7%
|
||||
|
||||
说明:**门禁通过 ≠ 业务语义无缺陷**。以下问题均是在门禁通过前提下仍然成立。
|
||||
|
||||
---
|
||||
|
||||
## 三、关键问题清单
|
||||
|
||||
### Critical-1:`/api/keys` 公共接口存在 subject 伪造风险,属于认证边界缺陷
|
||||
|
||||
- **文件**:
|
||||
- `internal/app/http_api.go` — `NewAPIHandlerWithAuth`
|
||||
- `internal/app/key_self_service.go` — `(*UserKeyHandler).extractSubjectID`
|
||||
- `deploy/tksea-portal/nginx.sub.tksea.top.conf.example`
|
||||
- **问题**:用户 key 自助接口未绑定可信用户身份源,而是直接信任来路请求头中的 subject。
|
||||
- **证据**:
|
||||
1. `NewAPIHandlerWithAuth` 将以下接口直接暴露为公共接口,没有 `requireAdminAccess` 包裹:
|
||||
- `POST /api/keys`
|
||||
- `GET /api/keys`
|
||||
- `GET /api/keys/{key_id}`
|
||||
- `POST /api/keys/{key_id}/reset`
|
||||
- `POST /api/keys/{key_id}/pause`
|
||||
- `POST /api/keys/{key_id}/resume`
|
||||
- `DELETE /api/keys/{key_id}`
|
||||
2. `extractSubjectID()` 直接接受 `X-Portal-Subject` / `X-User-Subject` / `X-Forwarded-User`,任一非空即作为真实 subject 使用;否则甚至会退化为用 `Authorization: Bearer ...` 前 8 位拼出 `skeleton_user_*`。
|
||||
3. `nginx.sub.tksea.top.conf.example` 中 `/portal-admin-api/` 只是普通反代,没有清洗或重建这些 header。
|
||||
4. 前端 `deploy/tksea-portal/index.html` 也是在浏览器侧自行构造 `X-Portal-Subject` 再发给 `/portal-admin-api/api/keys`。
|
||||
- **影响**:
|
||||
- 任意能访问 `/portal-admin-api/api/keys*` 的调用方,只要伪造 `X-Portal-Subject`,就可能代替其他用户创建、列出、重置、暂停、恢复或退役 key。
|
||||
- 这是直接的对象级授权绕过,不是低优先级硬化项。
|
||||
- **建议**:
|
||||
- `/api/keys*` 必须只接受**服务端可信身份**,不能信任浏览器自填 header。
|
||||
- 最小修复方案:由反向代理/认证层注入不可伪造身份,并在 CRM 侧校验来源;更稳妥的是 CRM 自己校验宿主登录态/JWT,再从服务端导出 subject。
|
||||
- 删除 `skeleton_user_*` 这种回退逻辑;它适合 demo,不适合生产认证边界。
|
||||
|
||||
### Critical-2:公网 `/v1/chat/completions` 会把上游失败伪装成本地成功 `HTTP 200`
|
||||
|
||||
- **文件**:
|
||||
- `internal/app/http_api.go` — `handlePublicV1ChatCompletions`
|
||||
- `internal/app/route_proxy_api.go` — `proxyChatCompletionToShadowHost`
|
||||
- **问题**:当 shadow host 返回 4xx/5xx 时,CRM 仍然向客户端返回 `200 OK`,并把结果记成 `ok` 指标。
|
||||
- **证据**:
|
||||
1. `proxyChatCompletionToShadowHost()` 在上游非 2xx 时,只设置:
|
||||
- `info.OK = false`
|
||||
- `info.UpstreamStatus = <4xx/5xx>`
|
||||
- `info.ErrorClass = ...`
|
||||
但**不返回 error**。
|
||||
2. `handlePublicV1ChatCompletions()` 只有 `proxyChat(...)` 返回 `err` 才走错误分支;否则无论 `result.Forward.OK` 是否为 false,最终都:
|
||||
- `metrics.RecordUserKeyChatRequest("ok")`
|
||||
- `w.WriteHeader(http.StatusOK)`
|
||||
3. 代码甚至在 `!result.Forward.OK` 时,只是往 JSON 里附加 `upstream_http_code` 字段,而不是透传失败状态码。
|
||||
- **影响**:
|
||||
- 客户端会把真实失败误判为成功,协议语义被破坏。
|
||||
- SLO/告警指标会把失败流量记成 `ok`,观测真相被污染。
|
||||
- 与执行板中强调的“真实闭环/失败路径可观测”目标相冲突。
|
||||
- **建议**:
|
||||
- 当 `result.Forward.OK == false` 时,必须返回对应 HTTP 状态码,并统一错误体。
|
||||
- `user_key_chat_requests_total` 必须按真实 outcome 记录,而不是只要本地代理函数没抛错就记 `ok`。
|
||||
- 为 4xx/5xx、上游非 JSON、超时等情形补回归测试。
|
||||
|
||||
### High-1:同一 subject + logical group 下的多个 key 实际会坍缩成同一把宿主 key,破坏 key 级治理语义
|
||||
|
||||
- **文件**:
|
||||
- `internal/host/sub2api/subscription_access.go` — `buildManagedSubscriptionIdentity`, `EnsureSubscriptionAccess`
|
||||
- `internal/app/key_self_service_svc.go` — `createFn`, `resetFn`
|
||||
- `internal/store/sqlite/user_keys_repo.go` — `ListByFingerprint`
|
||||
- `internal/app/http_api.go` — `handlePublicV1ChatCompletions`
|
||||
- **问题**:项目对同一 `subject + group` 生成的是**确定性宿主 key**,而不是每条 KeyRecord 独立的真实 key;这会让“创建多把 key / reset 某一把 key / pause 某一把 key”都失去 key 级隔离。
|
||||
- **证据**:
|
||||
1. `buildManagedSubscriptionIdentity(selector, groupID)` 使用 `selector|groupID` 的 SHA256 生成固定:
|
||||
- `CustomKey = "sk-relay-" + keyHash`
|
||||
2. `EnsureSubscriptionAccess()` 在非 real-subscription 模式下最终返回 `SubscriptionAccessRef{APIKey: identity.CustomKey}`。
|
||||
3. `createFn` / `resetFn` 都调用 `ensureSubjectHasAccess()`,因此同一 subject + group 会反复拿到**同一个明文 key**。
|
||||
4. `ListByFingerprint()` 允许返回多条同 fingerprint 记录,`handlePublicV1ChatCompletions()` 只取 `keys[0]`。
|
||||
5. 文档 `docs/2026-06-04-KEY_SELF_SERVICE_API.md` 明确要求:`reset` 后旧 key 失效,新 key 唯一可用。
|
||||
- **影响**:
|
||||
- 一个用户在同 logical group 下创建两条记录,本质上可能只是同一把宿主 key 的多个投影。
|
||||
- `pause/delete` 某条记录不一定能准确影响该明文 key 的可用性;效果取决于哪条重复 fingerprint 记录排在最前。
|
||||
- `reset` 不一定产生新 key,更谈不上“旧 key 立即失效”。
|
||||
- **建议**:
|
||||
- 把“KeyRecord 标识”和“宿主侧实际 key 材料”一一对应,禁止同一有效明文 key 被多个活跃记录共享。
|
||||
- 若业务上故意做“subject 级共享 key”,就必须删除当前 key-level pause/reset/delete 语义,避免伪装成独立 key。
|
||||
- 至少补唯一约束或冲突处理:活跃记录不能共享同一 `key_fingerprint`。
|
||||
|
||||
### High-2:`allowed_models` 已进入 API/DB/UI,但运行时完全未执行授权
|
||||
|
||||
- **文件**:
|
||||
- `internal/app/key_self_service.go`
|
||||
- `internal/app/key_self_service_svc.go`
|
||||
- `internal/app/http_api.go` — `handlePublicV1ChatCompletions`
|
||||
- `internal/store/migrations/0015_user_keys.sql`
|
||||
- `docs/2026-06-04-KEY_SELF_SERVICE_API.md`
|
||||
- **问题**:key 创建时接收并持久化 `allowed_models`,UI 也展示它,但实际调用 `/v1/chat/completions` 时没有任何模型级校验。
|
||||
- **证据**:
|
||||
1. `CreateUserKeyRequest`、`UserKeyMeta`、`UserKeyRecord` 均含 `AllowedModels`。
|
||||
2. Portal 页面创建 key 时会提交 `allowed_models`。
|
||||
3. 代码搜索结果显示,`allowed_models` 仅出现在 CRUD / 文档 / 测试数据中;在网关调用路径上没有任何“模型是否允许”的判断。
|
||||
4. `handlePublicV1ChatCompletions()` 仅校验:`admin_status`、`quota_status`、`model 非空`,随后直接把 `openAIReq.Model` 转发。
|
||||
- **影响**:
|
||||
- 对外宣称的 key 粒度模型授权只是展示字段,不是实际控制。
|
||||
- 用户可绕过 UI 选择,直接调用同 logical group 下任意路由可达模型。
|
||||
- **建议**:
|
||||
- 在 `handlePublicV1ChatCompletions()` 入站阶段强制校验 `model ∈ allowed_models`。
|
||||
- 若 `allowed_models` 只是提示字段,应从 API/文档/UI 中降级为 advisory,避免误导。
|
||||
|
||||
### High-3:`expires_at` 生命周期契约未落地,过期 key 仍可继续使用
|
||||
|
||||
- **文件**:
|
||||
- `internal/store/migrations/0015_user_keys.sql`
|
||||
- `internal/app/key_self_service.go`
|
||||
- `internal/app/http_api.go` — `handlePublicV1ChatCompletions`
|
||||
- `docs/2026-06-04-KEY_SELF_SERVICE_API.md`
|
||||
- `deploy/tksea-portal/index.html`
|
||||
- **问题**:系统保存并展示 `expires_at`,但网关与自助接口都不执行过期校验。
|
||||
- **证据**:
|
||||
1. migration 已定义 `expires_at` 字段。
|
||||
2. API 文档把它定义为 KeyRecord 字段,前端也展示“到期时间”。
|
||||
3. 代码搜索中,`expires_at` 只出现在 repo 读写、页面展示和文档;`handlePublicV1ChatCompletions()` 未检查它。
|
||||
- **影响**:
|
||||
- 过期只是 UI 文案,不是授权边界。
|
||||
- 运维、用户和文档会误以为 key 过期后自动失效,实际不会。
|
||||
- **建议**:
|
||||
- 在公共网关路径显式拒绝已过期 key。
|
||||
- 为列表/详情接口补一个派生状态,避免前后端各自解释过期语义。
|
||||
|
||||
### High-4:CI 与仓库声明的质量门禁不一致,且 Docker 验证基本失效
|
||||
|
||||
- **文件**:
|
||||
- `AGENTS.md`
|
||||
- `scripts/test/verify_quality_gates.sh`
|
||||
- `.github/workflows/ci.yml`
|
||||
- `Dockerfile`
|
||||
- **问题**:CI 没有执行仓库声明的完整质量门禁;Docker job 的“镜像测试”命令还指向错误路径并被 `|| true` 吞掉。
|
||||
- **证据**:
|
||||
1. 项目 `AGENTS.md` 明确要求:前端资产检查、frontend smoke、`go vet`、`go test -cover ./internal/...` 阈值、`go test ./tests/integration/...`、执行板同步等。
|
||||
2. `verify_quality_gates.sh` 已把这些门禁收口成一条脚本。
|
||||
3. `.github/workflows/ci.yml` 并未调用 `verify_quality_gates.sh`;只跑了:
|
||||
- `go build`
|
||||
- `go test -race ./internal/...`
|
||||
- 全局 coverage 60%
|
||||
- golangci-lint / gosec / govulncheck
|
||||
4. Docker job 中:
|
||||
- `docker run --rm sub2api-cn-relay-manager:test /app/server --version || true`
|
||||
- `docker run --rm sub2api-cn-relay-manager:test /app/cli --help || true`
|
||||
但镜像实际入口和二进制路径在 `Dockerfile` 中是 `/usr/local/bin/sub2api-cn-relay-manager`,并不存在 `/app/server`、`/app/cli`。
|
||||
- **影响**:
|
||||
- CI 绿并不代表仓库门禁绿。
|
||||
- Docker job 即使完全失效也会继续通过,无法证明镜像可运行。
|
||||
- **建议**:
|
||||
- CI 直接收口到 `bash ./scripts/test/verify_quality_gates.sh`。
|
||||
- Docker 验证改成真实入口探活,例如启动容器后访问 `/healthz`。
|
||||
- 删除 `|| true` 这种吞错写法。
|
||||
|
||||
### Medium-1:`pause` API 丢弃请求里的 `reason`,与文档承诺不一致
|
||||
|
||||
- **文件**:
|
||||
- `internal/app/key_self_service.go` — `handlePauseUserKey`
|
||||
- `internal/app/key_self_service_svc.go` — `pauseFn`
|
||||
- `docs/2026-06-04-KEY_SELF_SERVICE_API.md`
|
||||
- **问题**:文档声明 `POST /api/keys/:id/pause` 请求体可选 `reason`,且“暂停原因应对用户可见”;实际 handler 完全不解析请求体,直接把空字符串传给服务层。
|
||||
- **证据**:
|
||||
1. `handlePauseUserKey()` 直接调用 `pauseFn(..., "")`。
|
||||
2. `pauseFn()` 虽然接收 `reason string`,也会写入审计事件,但现在永远拿不到请求值。
|
||||
3. 文档明确写了“请求体可选 `reason`”“暂停原因应对用户可见”。
|
||||
- **影响**:
|
||||
- 审计记录缺失关键上下文。
|
||||
- 文档、API 和实际行为不一致。
|
||||
- **建议**:
|
||||
- 明确 pause request schema,解析并持久化 reason。
|
||||
- 若短期不支持,删除文档承诺和“对用户可见”的表述。
|
||||
|
||||
### Medium-2:`last_used_at` 只定义不更新,运营可观测字段失真
|
||||
|
||||
- **文件**:
|
||||
- `internal/store/sqlite/user_keys_repo.go` — `TouchLastUsed`
|
||||
- `internal/app/http_api.go` — `handlePublicV1ChatCompletions`
|
||||
- `docs/2026-06-04-KEY_SELF_SERVICE_API.md`
|
||||
- `deploy/tksea-portal/index.html`
|
||||
- **问题**:仓库提供了 `TouchLastUsed()`,API/UI 也展示 `last_used_at`,但实际调用链没有地方更新它。
|
||||
- **证据**:
|
||||
1. `TouchLastUsed()` 已实现。
|
||||
2. 搜索结果显示没有任何调用方。
|
||||
3. 页面展示“最近使用”,文档也把它定义为标准字段。
|
||||
- **影响**:
|
||||
- 门户与运营人员会看到长期为空或过期的数据。
|
||||
- 配额治理、冷 key 清理、审计回溯都会缺失基本事实源。
|
||||
- **建议**:
|
||||
- 在成功代理调用后更新 `last_used_at`。
|
||||
- 如果担心同步写放大,可异步写或批量聚合,但不能一直只定义不落地。
|
||||
|
||||
### Medium-3:部署脚本默认值过于危险,容易误打生产环境
|
||||
|
||||
- **文件**:`scripts/deploy/deploy_tksea_portal.sh`
|
||||
- **问题**:部署脚本内置了具体生产 IP、默认 SSH key 本地路径和生产端口。
|
||||
- **证据**:
|
||||
- `KEY=/home/long/下载/zjsea.pem`
|
||||
- `REMOTE=ubuntu@43.155.133.187`
|
||||
- `REMOTE_CRM_PORT=18190`
|
||||
- **影响**:
|
||||
- 新环境复用困难。
|
||||
- 在错误上下文直接执行时,有误操作生产的风险。
|
||||
- **建议**:
|
||||
- 把这些值移到显式 env / `.env.deploy.example`。
|
||||
- 缺省值应偏向安全失败,而不是默认命中生产。
|
||||
|
||||
## 三点五、第二轮异构方法补充发现
|
||||
|
||||
第二轮没有复用上一轮的主阅读路径,改用以下方法补查:
|
||||
|
||||
- 异常模式扫描:`search` 检查 `localStorage` 持久化、忽略错误、固定凭证生成、鉴权回退等模式
|
||||
- 重点源码回读:对命中的 handler / portal 页面 / host adapter / repo 解码逻辑做定点复核
|
||||
- LSP 诊断尝试:当前会话未在项目根激活 Go LSP,因此未产出额外语言服务器诊断;结论以下述源码证据为准
|
||||
|
||||
## 四、2026-06-08 当前整改状态(追加)
|
||||
|
||||
- 已本地修复并验证:
|
||||
- Critical-2:公网 `/v1/chat/completions` 对上游失败已返回真实失败状态,不再包装成 `200/ok`
|
||||
- High-1:同 `subject + logical_group` 的多条 key record 已改为独立 `managed_identity_selector`,`create/reset/pause/resume` 不再复用同一宿主 key
|
||||
- High-2:`allowed_models` 已在公网 chat 入口强制执行
|
||||
- High-3:`expires_at` 已在公网 chat 入口强制执行
|
||||
- Medium-1:`pause` 已解析请求体 `reason`
|
||||
- Medium-2:成功 chat 后会更新 `last_used_at`
|
||||
- 本地验证:
|
||||
- `gofmt -w` 目标文件通过
|
||||
- `go vet ./...` 通过
|
||||
- `go test ./internal/app ./internal/store/sqlite ./tests/integration/... -count=1` 通过
|
||||
- 尚未完成的最终闭环:
|
||||
- remote43 当前 nginx `/portal-admin-api/` 仍未注入 trusted subject / proxy secret
|
||||
- remote43 当前 `.env.crm` 仍缺 `SUB2API_CRM_TRUSTED_*`
|
||||
- 因此新的线上 user-key 真验尚未完成;需先补生产 trusted-subject 链,随后再跑真实 `POST /api/keys` + `POST /v1/chat/completions = 200` 验收
|
||||
|
||||
### High-5:Portal 管理页把 Bearer token、probe key、provider keys 持久化到 `localStorage`,且与页面文案相矛盾
|
||||
|
||||
- **文件**:
|
||||
- `deploy/tksea-portal/admin-common.js` — `readStoredConfig`, `writeStoredConfig`
|
||||
- `deploy/tksea-portal/admin/accounts.html` — `writeConfig`
|
||||
- `deploy/tksea-portal/admin/providers.html` — `saveConfig`
|
||||
- `deploy/tksea-portal/admin/logical-groups.html` — `saveConfig`
|
||||
- `deploy/tksea-portal/admin/route-health.html` — `saveConfig`
|
||||
- `deploy/tksea-portal/admin-batch-import.html` — `saveConfig`
|
||||
- `deploy/tksea-portal/admin/index.html`
|
||||
- **问题**:多个管理页把高敏感凭证长期写入浏览器 `localStorage`,包括 `adminToken`、`probeAPIKey`、`accessAPIKey`、`providerKeys`、batch import `entries`;但首页文案明确写着 Bearer Token “不落盘,仅当前会话”。
|
||||
- **证据**:
|
||||
1. `admin-common.js` 的 `writeStoredConfig()` 直接执行 `global.localStorage.setItem(storageKey, JSON.stringify(payload))`。
|
||||
2. `accounts.html` / `logical-groups.html` / `route-health.html` / `providers.html` / `admin-batch-import.html` 都把 `adminToken` 写入存储配置。
|
||||
3. `providers.html` 还会持久化:
|
||||
- `accessAPIKey`
|
||||
- `providerKeys`
|
||||
4. `admin-batch-import.html` 还会持久化:
|
||||
- `probeAPIKey`
|
||||
- `entries`
|
||||
5. `admin/index.html` 第 273-275 行的 UI 提示仍写着:`Bearer Token(可选)` / `不落盘,仅当前会话`。
|
||||
- **影响**:
|
||||
- 任何 XSS、浏览器扩展、共享机器、浏览器 profile 泄漏,都会直接暴露管理员 Bearer token 与第三方供应商 key 材料。
|
||||
- 这不是单纯 UX 漂移,而是前端凭证驻留策略错误。
|
||||
- **建议**:
|
||||
- 默认禁止把任何 token / key / entries 写入 `localStorage`。
|
||||
- 如确有调试需求,改成显式“记住敏感信息”开关,默认关闭,并单独标红提示风险。
|
||||
- 首页与各页面文案必须与真实持久化行为保持一致。
|
||||
|
||||
### High-6:managed subscription 的宿主账号密码与 API key 完全由 `selector + groupID` 确定性推导,凭证可预测
|
||||
|
||||
- **文件**:
|
||||
- `internal/host/sub2api/subscription_access.go` — `buildManagedSubscriptionIdentity`, `createManagedSubscriptionUser`, `loginAsManagedSubscriptionUser`, `ensureManagedSubscriptionAPIKey`
|
||||
- **问题**:系统不是生成随机宿主侧托管用户密码/托管 API key,而是直接用 `selector|groupID` 的哈希构造固定邮箱、固定密码、固定 custom key。
|
||||
- **证据**:
|
||||
1. `buildManagedSubscriptionIdentity()` 中:
|
||||
- `Email = fmt.Sprintf("%s-%s@sub2api.local", prefix, shortHash)`
|
||||
- `Password = "RelayPwd!" + hash[:12]`
|
||||
- `CustomKey = "sk-relay-" + keyHash`
|
||||
2. `createManagedSubscriptionUser()` 用这个固定密码创建宿主用户。
|
||||
3. `loginAsManagedSubscriptionUser()` 随后用同一个固定密码去 `/api/v1/auth/login`。
|
||||
4. `ensureManagedSubscriptionAPIKey()` 把 `identity.CustomKey` 作为 `custom_key` 提交给宿主。
|
||||
- **影响**:
|
||||
- 只要 `selector` 和宿主 `groupID` 可推断,同一套宿主凭证就可被离线重建;这与“reset 后获得新 key”目标天然冲突。
|
||||
- 它也让托管用户/托管 key 的秘密性依赖于业务标识不可猜,而不是依赖随机熵。
|
||||
- **建议**:
|
||||
- 宿主侧用户密码、custom key 必须改成高熵随机值,并由 CRM 服务端持久化管理。
|
||||
- `selector/groupID` 可以作为索引键,但不能直接当作凭证种子。
|
||||
- 若需要可重建映射,应只重建“查找键”,不要重建“登录秘密”。
|
||||
|
||||
### Medium-4:`user_keys.allowed_models` 的 JSON 解码错误被静默吞掉,数据损坏会被伪装成“空模型列表”
|
||||
|
||||
- **文件**:`internal/store/sqlite/user_keys_repo.go` — `scanUserKeys`, `scanOneUserKey`
|
||||
- **问题**:repo 在读取 `allowed_models` 时调用 `json.Unmarshal(...)`,但不检查返回错误。
|
||||
- **证据**:
|
||||
1. `scanUserKeys()`:
|
||||
- `json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)`
|
||||
2. `scanOneUserKey()`:
|
||||
- `json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)`
|
||||
3. 现有 repo 测试只覆盖正常 JSON,未覆盖损坏数据分支。
|
||||
- **影响**:
|
||||
- 一旦库中 `allowed_models` 被历史脚本、手工修复、坏迁移写坏,API 不会报错,只会悄悄返回空列表。
|
||||
- 今天它首先表现为“事实源失真”;[INFERENCE] 如果后续补上模型授权强校验,这类静默降级会进一步变成授权行为不可预测。
|
||||
- **建议**:
|
||||
- 解码失败时直接返回错误,而不是吞掉。
|
||||
- 为 `ListByOwner` / `GetByID` 增加 malformed JSON 测试用例。
|
||||
|
||||
### 第二轮补充结论
|
||||
|
||||
- 第一轮的主结论 **没有被推翻**:认证边界与网关错误语义仍是最需要优先修复的问题。
|
||||
- 第二轮额外确认:**前端管理面的凭证驻留策略** 和 **宿主托管身份的凭证生成策略** 也存在实质性安全问题,不能只当成文档偏差处理。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 四、正向评价
|
||||
|
||||
以下设计/工程实践值得肯定:
|
||||
|
||||
1. **SQLite repo 层组织清晰**
|
||||
`internal/store/sqlite` 基本遵循 repo pattern,迁移、查询、边界测试较完整,便于后续维护。
|
||||
|
||||
2. **质量门禁脚本收口做得好**
|
||||
`scripts/test/verify_quality_gates.sh` 已把前端资产、浏览器 smoke、SLO 门禁、gofmt、vet、coverage、integration test 串成一条可执行基线。
|
||||
|
||||
3. **导入/访问闭环核心覆盖率较高**
|
||||
`internal/access`、`internal/provision`、`internal/store/sqlite`、`internal/reconcile` 的测试密度明显高于普通原型仓库。
|
||||
|
||||
4. **真实宿主 artifact 沉淀充分**
|
||||
`artifacts/` 与执行板/真相文档联动,减少了“以为通过”和“真实通过”混淆。
|
||||
|
||||
5. **路由/治理/观测的概念边界基本清楚**
|
||||
`route resolve`、`sticky`、`failover`、`governance`、`SLO` 等概念在命名和文档上已逐步收口,不是无结构堆叠。
|
||||
|
||||
6. **HTTP Server 基础超时配置合理**
|
||||
`ReadTimeout`、`ReadHeaderTimeout`、`WriteTimeout`、`IdleTimeout`、`MaxHeaderBytes` 都已设置,优于默认裸奔。
|
||||
|
||||
7. **SQLite 单写连接限制是有意识设计**
|
||||
`SetMaxOpenConns(1)` 针对 SQLite writer 约束有明确注释说明,避免了部分自锁型 `SQLITE_BUSY` 问题。
|
||||
|
||||
---
|
||||
|
||||
## 五、整改优先级建议
|
||||
|
||||
### P0(必须先改)
|
||||
|
||||
1. 修复 `/api/keys*` 的 subject 信任模型,消除 header 伪造。
|
||||
2. 修复 `/v1/chat/completions` 的错误码透传与指标统计,禁止把上游失败记成 200/ok。
|
||||
3. 修复“同 subject + group 复用同一明文 key”导致的 key 级治理语义坍缩。
|
||||
|
||||
### P1(紧随其后)
|
||||
|
||||
4. 落地 `allowed_models` 强制校验。
|
||||
5. 落地 `expires_at` 生效逻辑。
|
||||
6. 让 CI 与 `verify_quality_gates.sh` 对齐,并修复 Docker job 假验证。
|
||||
|
||||
### P2(补齐契约与运营真相)
|
||||
|
||||
7. 实现 `pause reason` 请求/持久化/展示闭环。
|
||||
8. 在成功调用后更新 `last_used_at`。
|
||||
9. 收敛部署脚本默认值,避免隐式命中生产。
|
||||
|
||||
---
|
||||
|
||||
## 六、最终判断
|
||||
|
||||
如果评价标准是:
|
||||
|
||||
- **代码能跑、测试能过、已有较强工程基础** —— 结论是 **是**。
|
||||
- **认证、key 治理、网关错误语义已经达到严格生产级** —— 结论是 **否**。
|
||||
|
||||
当前最值得警惕的不是普通 bug,而是两类“表面通过、语义失真”的问题:
|
||||
|
||||
1. **认证边界靠客户端自报身份**。
|
||||
2. **上游失败被本地包装成成功**。
|
||||
|
||||
这两类问题都足以让线上行为与控制面/指标/审计出现系统性偏差,建议优先按 P0 处理后,再谈更高等级放行。
|
||||
@@ -20,6 +20,33 @@
|
||||
2. `portal-admin-api` nginx 反代自动指向 18190(新 CRM)
|
||||
3. `/metrics` Prometheus 端点已在公网通过 portal-admin-api 反代可访问
|
||||
|
||||
## 2026-06-08 review remediation 当前真相
|
||||
|
||||
- 本地已完成并验证的整改:
|
||||
- `/v1/chat/completions` 上游失败不再包装成 `200/ok`
|
||||
- `allowed_models` 已在公网 chat 入口强制校验
|
||||
- `expires_at` 已在公网 chat 入口强制校验
|
||||
- 成功 chat 后会更新 `last_used_at`
|
||||
- `pause` handler 已接入请求体 `reason`
|
||||
- 同一 `subject + logical_group` 不再复用同一宿主 key;现改为每条 key record 持久化独立 `managed_identity_selector`,`create/reset/pause/resume` 走当前 selector
|
||||
- 新增 migration:`internal/store/migrations/0016_user_keys_managed_identity_selector.sql`
|
||||
- 本地验证(2026-06-08 当前运行):
|
||||
- `gofmt -w` 目标文件通过
|
||||
- `go vet ./...` 通过
|
||||
- `go test ./internal/app ./internal/store/sqlite ./tests/integration/... -count=1` 通过
|
||||
- 当前线上阻塞:
|
||||
- ✅ **已解决** (2025-06-09): vNext.4 Trusted-Subject 安全链实施完成
|
||||
- 新文件: `internal/app/portal_auth.go` - Portal user session 认证模块
|
||||
- 变更: `http_api.go`, `bootstrap.go`, `.env.example`, `nginx.sub.tksea.top.conf.example`
|
||||
- 前端: `index.html` 添加 CRM session 登录/登出
|
||||
- 文档: `docs/TRUSTED_SUBJECT_DEPLOY_GUIDE.md` 完整部署指南
|
||||
- 本地验证: `go test ./internal/app -run TestPortal` 全部通过
|
||||
- **待 remote43 部署**:
|
||||
- 需更新 nginx 配置(添加 cookie-to-header map)
|
||||
- 需更新 `.env.crm`(配置 TRUSTED\_\* 环境变量)
|
||||
- 需生成并同步 64 字符 hex secret
|
||||
- 详见部署指南文档
|
||||
|
||||
## 2026-06-05 vNext.2 / V2-4 真实闭环
|
||||
|
||||
- 已完成 user-key self-service 第二轮实现并部署到 remote43 生产 CRM:
|
||||
|
||||
194
docs/TRUSTED_SUBJECT_DEPLOY_GUIDE.md
Normal file
194
docs/TRUSTED_SUBJECT_DEPLOY_GUIDE.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# vNext.4 Trusted-Subject 安全链部署指南
|
||||
|
||||
> 解决 2026-06-08 EXECUTION_BOARD.md 中记录的线上阻塞问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
remote43 当前 nginx `/portal-admin-api/` 未注入 `X-CRM-Authenticated-Subject` / `X-CRM-Trusted-Proxy`,导致无法在现网安全完成新的 user-key 真验闭环。
|
||||
|
||||
## 解决方案
|
||||
|
||||
实施受信代理安全链:
|
||||
|
||||
```
|
||||
用户浏览器 ← → Portal 前端 ← → nginx (cookie→header 转换) ← → CRM
|
||||
↑ ↓
|
||||
设置 httpOnly cookie 验证并注入受信 header
|
||||
```
|
||||
|
||||
## 所需变更
|
||||
|
||||
### 1. CRM 二进制更新 (已完成)
|
||||
|
||||
新增文件:
|
||||
|
||||
- `internal/app/portal_auth.go` - Portal user session 认证模块
|
||||
- `internal/app/portal_auth_test.go` - 测试用例
|
||||
|
||||
变更文件:
|
||||
|
||||
- `internal/app/http_api.go` - 添加 `/api/portal/session/*` 路由
|
||||
- `internal/app/bootstrap.go` - 传递 trusted proxy secret
|
||||
- `deploy/tksea-portal/index.html` - 添加 CRM session 登录/登出
|
||||
- `deploy/tksea-portal/nginx.sub.tksea.top.conf.example` - nginx 配置模板
|
||||
- `.env.example` - 环境变量模板
|
||||
|
||||
### 2. remote43 部署步骤
|
||||
|
||||
#### 步骤 1: 生成共享密钥
|
||||
|
||||
在 remote43 上执行:
|
||||
|
||||
```bash
|
||||
TRUSTED_PROXY_SECRET=$(openssl rand -hex 32)
|
||||
echo "Generated secret: $TRUSTED_PROXY_SECRET"
|
||||
# 保存此密钥,需要同时配置到 nginx 和 CRM
|
||||
```
|
||||
|
||||
#### 步骤 2: 更新 CRM 配置
|
||||
|
||||
编辑 `/home/ubuntu/.env.crm`(或实际运行目录):
|
||||
|
||||
```bash
|
||||
# 在文件末尾添加:
|
||||
# Trusted Subject Proxy Configuration
|
||||
SUB2API_CRM_TRUSTED_SUBJECT_HEADER=X-CRM-Authenticated-Subject
|
||||
SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER=X-CRM-Trusted-Proxy
|
||||
SUB2API_CRM_TRUSTED_PROXY_SECRET=<步骤1生成的64字符密钥>
|
||||
```
|
||||
|
||||
#### 步骤 3: 更新 nginx 配置
|
||||
|
||||
编辑 `/etc/nginx/sites-enabled/sub.tksea.top.conf`:
|
||||
|
||||
在 `server` 块内添加:
|
||||
|
||||
```nginx
|
||||
# 从 httpOnly cookie 提取 portal subject(放在 server 块内)
|
||||
map $http_cookie $portal_subject {
|
||||
default "";
|
||||
~*crm_session=([^;]+) $1;
|
||||
}
|
||||
```
|
||||
|
||||
修改 `/portal-admin-api/` location:
|
||||
|
||||
```nginx
|
||||
location /portal-admin-api/ {
|
||||
proxy_pass http://127.0.0.1:18190/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 关键:从验证过的 cookie 提取并注入 subject
|
||||
proxy_set_header X-CRM-Authenticated-Subject $portal_subject;
|
||||
# 受信代理密钥(必须与 CRM 配置一致)
|
||||
proxy_set_header X-CRM-Trusted-Proxy "<步骤1生成的64字符密钥>";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
|
||||
- 删除原来的 `proxy_set_header X-Portal-Subject "";` 行
|
||||
- 确保密钥替换为实际生成的 64 字符 hex 字符串
|
||||
|
||||
#### 步骤 4: 重启服务
|
||||
|
||||
```bash
|
||||
# 测试 nginx 配置
|
||||
sudo nginx -t
|
||||
|
||||
# 重载 nginx
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# 重启 CRM
|
||||
sudo systemctl restart crm
|
||||
# 或使用:
|
||||
# pkill -f "crm" && cd /home/ubuntu && ./server &
|
||||
```
|
||||
|
||||
#### 步骤 5: 验证
|
||||
|
||||
浏览器测试:
|
||||
|
||||
1. 访问 `https://sub.tksea.top/portal/`
|
||||
2. 登录(会同时设置 CRM session cookie)
|
||||
3. 打开浏览器 DevTools → Application → Cookies
|
||||
4. 确认看到 `crm_session` cookie(httpOnly)和 `crm_subject` cookie
|
||||
5. 尝试创建/管理用户 Key,应该可以正常工作
|
||||
|
||||
API 测试:
|
||||
|
||||
```bash
|
||||
# 1. 登录获取 session cookie
|
||||
curl -c cookies.txt -X POST https://sub.tksea.top/portal-admin-api/api/portal/session/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}'
|
||||
|
||||
# 2. 使用 cookie 访问 user-key API
|
||||
curl -b cookies.txt https://sub.tksea.top/portal-admin-api/api/keys
|
||||
|
||||
# 3. 创建新 key
|
||||
curl -b cookies.txt -X POST https://sub.tksea.top/portal-admin-api/api/keys \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key_name":"test-key","logical_group_id":"gpt-shared"}'
|
||||
```
|
||||
|
||||
### 3. 故障排除
|
||||
|
||||
#### CRM 返回 `unauthorized` / `trusted proxy authentication required`
|
||||
|
||||
- 检查 `.env.crm` 中的 `SUB2API_CRM_TRUSTED_PROXY_SECRET` 是否正确设置
|
||||
- 检查 nginx 中的 `X-CRM-Trusted-Proxy` header 值是否一致
|
||||
- 检查两个密钥是否完全匹配(无多余空格)
|
||||
|
||||
#### CRM 返回 `trusted subject header required`
|
||||
|
||||
- 检查 nginx 是否正确添加了 `map $http_cookie $portal_subject`
|
||||
- 检查浏览器是否有 `crm_session` cookie(登录后应该自动设置)
|
||||
- 检查 cookie 是否被浏览器阻止(SameSite/Secure 设置)
|
||||
|
||||
#### Portal 前端无法登录 CRM session
|
||||
|
||||
- 检查浏览器 console 是否有 CORS 错误
|
||||
- 检查 `/portal-admin-api/` location 是否正确配置
|
||||
- 确认 CRM 服务正在监听 `127.0.0.1:18190`
|
||||
|
||||
## 安全配置建议
|
||||
|
||||
1. **密钥管理**
|
||||
- 使用 `openssl rand -hex 32` 生成强密钥
|
||||
- 不要在任何地方记录或提交密钥
|
||||
- 考虑使用 HashiCorp Vault 或 AWS Secrets Manager
|
||||
|
||||
2. **HTTPS**
|
||||
- 生产环境必须启用 HTTPS
|
||||
- 设置 `Secure` flag 在 cookies 上
|
||||
|
||||
3. **Cookie 设置**
|
||||
- `crm_session`: httpOnly, SameSite=Lax, Secure (HTTPS only)
|
||||
- `crm_subject`: SameSite=Lax, Secure (HTTPS only)
|
||||
|
||||
## 回滚计划
|
||||
|
||||
如果需要回滚:
|
||||
|
||||
1. 还原 nginx 配置(删除 map 和 header 设置)
|
||||
2. 还原 `.env.crm`(移除 TRUSTED\_\* 配置)
|
||||
3. 重载 nginx / 重启 CRM
|
||||
|
||||
portal 会回退到之前的 bearer token 认证模式。
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] 生成并记录了 64 字符 hex secret
|
||||
- [ ] 更新了 `.env.crm` 配置
|
||||
- [ ] 更新了 nginx 配置
|
||||
- [ ] 重载了 nginx 配置
|
||||
- [ ] 重启了 CRM 服务
|
||||
- [ ] 浏览器测试通过(可以看到 crm_session cookie)
|
||||
- [ ] API 测试通过(可以创建 user-key)
|
||||
- [ ] 完整链路测试通过(create → chat → pause → resume → delete)
|
||||
Reference in New Issue
Block a user