1. 页面概览
1.1 是什么
「签名者白名单」页(对应前端源码 action/web/src/views/apollo/SignersPage.vue,页面标题「Apollo 签名者白名单」)是 LightApollo 制品签名信任锚(G1)的管理页。产品在国密体系下用 SM2 私钥对制品 bundle 签名、用白名单里的公钥验签——哪些签名者「可信」不是靠密钥算法本身,而是靠这张 trusted_signers 白名单表:白名单里 enabled=true 的条目才被当作信任锚,bundle 验签时用制品 manifest 里的 signer_fingerprint 去白名单里查公钥,查不到或被禁用就拒绝应用(VERIFY_SIGNER_NOT_TRUSTED)。一句话概括本页职责:管理「谁签的东西可以信」。
页面能做的事分三类:新建(表单收集名称 + 公钥指纹 + 公钥 + 描述 + 启用开关,指指纹与名称均全局唯一)、启用/禁用(一键改白名单信任状态,禁用即拉黑——该签名者签的 bundle 立即验签失败)、删除(把条目从白名单除名,删除前 confirm 二次确认并明示后果「删除后其签名的 bundle 将无法通过验签」)。主区是签名者列表表格:ID / 名称 / 公钥指纹(列表只显示前 24 位 + …,悬停看全文)/ 状态徽标 / 创建时间 / 操作。
理解本页要先理解 G1 信任锚在整条验签链里的位置。bundle 的验签校验顺序(详见「制品与渠道」页)是:逐文件 SM3 校验和 → 重算 digest → 用 signer_fingerprint 在白名单查公钥(enabled=true) → 用白名单公钥对 digest 做 SM2 验签。第 3 步就是本页管的数据:白名单「有没有、禁没禁」直接决定第 3 步过不过。白名单里没有该签名者、或条目被禁用,第 3 步返回 403 VERIFY_SIGNER_NOT_TRUSTED,bundle 即使加密学上签名有效也拒绝应用——这是「杜绝任意有效签名」的关键设计:密码学只证明"这把私钥签的",白名单才决定"这把私钥可信不可信"。
本页与「制品与渠道」页的关系最紧密:bundle 在制品页构建/验签,签名者信任在这里管理。典型的演示闭环是:首次构建 bundle 时,bootstrap 种子里的演示签名者 demo-signer 会自动从占位指纹升级为真实 SM2 密钥指纹并登记白名单(见 5.4),此后在制品页点「验签」四项全绿;回到本页把 demo-signer 点「禁用」,再回制品页对同一 bundle 验签就得到 403 信任锚失败;再回本页点「启用」恢复全绿。禁用/启用操作在本页「即时生效」,但它真正的作用在制品页的验签结果上体现。
页面本身是一个偏「简单直白」的管理列表:没有分页、没有筛选、没有编辑入口、没有环境维度下拉。列表一次性返回全量白名单行(后端按 id 升序),新建后自动刷新列表。需要留意的是:**后端比页面多一个 PUT /signers/:id 更新接口与 environment_id(环境级白名单,F6)维度**,这两者当前页面表单都没有暴露——环境级白名单条目只能通过 API 创建,创建后也会混在本页列表里展示(但页面上没有环境归属列,无法肉眼区分全局/环境级,详见第 8 章)。
从使用者视角看,本页是安全管理员的操作台,而不是普通开发者的日常页:新增一个可信签名者意味着「此后该签名者签名的制品能在平台应用」,停用/删除意味着「立刻切断某把信任根的制品通道」。信任关系一旦变更会持续影响后续所有相关 bundle 的验签结果,因此页面对删除做了 confirm 二次确认、对所有写操作做了 busy 互斥,并把每笔增删启停都计入 HTTP 审计(在「审计日志」页按 signers 资源可回溯「谁在何时改了什么」)。与之配套的管理页有清晰的职责边界:「用户/角色/API Key 管理」决定谁有 signer:read/signer:write 权限,「制品与渠道」消费这里的信任锚做四步验签,「审计日志」留痕这里的每次变更,本页则只维护「白名单本身长什么样」这一件事。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| 信任锚增删 | 新建白名单条目(name + 公钥指纹必填)、删除条目 | 「新建签名者」弹窗、「删除」按钮 |
| 一键启停 | 启用/禁用切换白名单信任状态,即时影响 bundle 验签 | 行内「启用」/「禁用」按钮 |
| 指纹即键 | public_key_fingerprint 是验签匹配键,全局唯一 | 指纹输入框 + 唯一索引 |
| 除名即失效 | 删除签名者后,其签发的 bundle 一律验签失败 | 「删除」+ confirm 二次确认 |
| 全局信任 | 白名单为全局 trusted_signers 表,不随 bundle 删除或制品清理而消失 | 列表展示(常驻) |
| 演示自举 | bootstrap 种子生成 demo-signer,首次构建 bundle 自动升级真实指纹 | 列表可见 demo-signer 行 |
1.3 一句话总结
白名单里放行的签名者签的制品才可信——「启用」是放行、「禁用」是拉黑、「删除」是除名,删除后其签发的 bundle 一律无法通过验签。
2. 访问入口
2.1 路由与菜单
| 项 | 值 |
|---|---|
| 路由 path | /apollo/admin/signers |
| 路由 name | ApolloAdminSigners |
| meta.title | Apollo 签名者白名单 |
| meta.requiresAuth | true |
| 父布局 | /apollo(ApolloLayout,左侧固定侧边栏) |
| 侧边栏入口 | ApolloLayout 侧边栏「平台管理」分组下的「签名者白名单」 |
| 前端源码 | action/web/src/views/apollo/SignersPage.vue |
| API 客户端 | action/web/src/api/apolloClient.js |
| 路由注册 | action/web/src/router/index.js,/apollo 子路由 children 下 admin/signers |
相邻页(侧边栏「平台管理」分组内顺序):项目管理 admin-projects.md(前)、审计日志 admin-audit-logs.md(前,签名者增删启停会出现在审计日志里)、本页是分组最后一项;其后是分隔线与独立登录入口「登录 Apollo」。制品侧关联页:「制品与渠道」bundles.md(bundle 构建与四步验签,本页管理的信任锚在其中消费)、「环境管理」environments.md(F6 环境级白名单按部署目标环境 scope 解析)。
在浏览器里的到达路径是:先以 Apollo 账号登录(/apollo/login,登录成功后写入 apollo_token),落地 /apollo 部署总览页;随后点击左侧 Apollo 侧边栏,向下滚动到「平台管理」分组(分组之上依次是部署总览/部署与漂移/环境管理/Git 仓库/制品管理/流水线/监控/告警自愈/安全合规/配置管理/制品与渠道/Spoke Agent),分组内最后一项即「签名者白名单」。也可以直接在地址栏输入完整 URL /apollo/admin/signers 回车直达:若已登录会直接渲染本页,未登录则被全局路由守卫拦到 /apollo/login,登录后不自动回跳(需要重新点菜单或再输一次 URL)。登录态与后端 AIP 用户库相互独立:即使已在 AIP 登录过,进入 Apollo 管理页仍要求 Apollo 侧有有效登录;旧版遗留的 aip_token 已不再作为兼容回退(2026-09-12 修订,仅认 apollo_token)。
2.2 认证与权限
- 路由挂
requiresAuth: true:未登录访问被前端全局守卫重定向到 Apollo 独立登录页/apollo/login(登录态为独立apollo_token,2026-09-12 修订后仅认apollo_token,不再回退读取旧aip_token)。 - 列表要求后端权限点
signer:read;新建/启用/禁用/删除要求signer:write。403 时由 apolloClient 统一alert('无权限执行该操作')。 - 前端 401(apollo_token 失效)由响应拦截器清除 token 并跳转
/apollo/login。
权限在演示环境的管理口径是:具备管理员角色的账号默认持有 signer:read 与 signer:write;被裁剪为只读角色的账号可以看到白名单、但不能新建/启停/删除(按钮点击即触发 403 alert,后端才是真正的判权限方——前端按钮没有按权限点做显隐)。权限点字符串与后端 RequirePerm 校验、以及角色/API Key 的权限清单严格一致,改权限在「角色管理」「API Key 管理」页进行,不在本页。
2.3 端口与 API 前缀
- Apollo 后端端口 18082。Vite 开发代理把
/apollo-api前缀 rewrite 为/api转发(后端路由注册在/api/v1),客户端 baseURL/apollo-api/v1,请求超时 30000ms。 - 统一响应 envelope
{code,message,data,request_id}:拦截器在code===0时把response.data解包为业务数据,页面const { data } = await apiClient.get(...)直接拿业务数组。
举个实际联调的例子便于直连调试:后端真实监听在 http://127.0.0.1:18082,所以绕过前端时访问的是 GET http://127.0.0.1:18082/api/v1/signers(带 Authorization: Bearer <apollo_token>),响应包着 envelope;而在浏览器前端里同一请求的 URL 是 /apollo-api/v1/signers,经 Vite 代理 rewrite 后等价。二者路径差了 /apollo-api/v1 与 /api/v1 两段前缀,别在调试脚本里搞混。列表接口不走分页 envelope 变体(okPage),直接 ok(c, list) 返回裸数组包在 data 里;这与「项目管理」页的分页列表形态不同——本页体量小,一次性全量返回。
3. 界面布局
页面为「页头 + 提示条 + 新建弹窗 + 单列表卡片」的单列布局:
┌──────────────────────────────────────────────────────────────┐
│ Apollo 签名者白名单 [新建签名者] │
│ [alert 操作结果提示条(v-if alert.message,右侧「关闭」)] │
│ ┌ 新建签名者弹窗(modal-overlay,@click.self 可关)───────────┐ │
│ │ 名称(name)* [如 demo-signer] │ │
│ │ 公钥指纹(...)* [如 9f86d081884c7d65...] │ │
│ │ 公钥(public_key,选填) [PEM 公钥内容(可选) textarea] │ │
│ │ 描述(description,选填) [签名者用途说明] │ │
│ │ ☑ 启用(加入白名单信任) │ │
│ │ [创建签名者] [取消] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ┌ 签名者列表(card)────────────────────────────────────────┐ │
│ │ 加载中:「加载中...」 │ │
│ │ 空态:「暂无签名者,点击"新建签名者"加入白名单。」 │ │
│ │ 表格:ID | 名称 | 公钥指纹 | 状态 | 创建时间 | 操作 │ │
│ │ (已启用行:[禁用][删除];已禁用行:[启用][删除]) │ │
│ └───────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
各板块职责:
| 板块 | 职责 |
|---|---|
| 页头 | 标题「Apollo 签名者白名单」+ 右上「新建签名者」主按钮 |
| 操作结果提示条 | 页头下方 alert,info/success/error 三型,可「关闭」;单槽覆盖 |
| 新建签名者弹窗 | 收集 name/指纹/公钥/描述/enabled,提交或取消 |
| 签名者列表卡片 | 三种状态:加载中 / 空态 / 表格;表格列出全部白名单条目 |
列表为后端全量返回(不分页),按 id 升序排列;行内固定提供两枚操作按钮(按 enabled 切换「禁用/启用」)+「删除」。页面是纯 CRUD 形态,没有分页器、搜索框、批量操作或自动轮询。
渲染上的三态切换值得留意:v-if="loading"(灰字居中「加载中...」)→ v-else-if="signers.length===0"(空态灰字「暂无签名者,点击"新建签名者"加入白名单。」)→ v-else 表格。首次进入页面 loading 初始为 true,onMounted 触发 fetchSigners(),列表加载完成前一直显示「加载中...」;加载失败不会退到空态,而是弹红色 alert「加载签名者列表失败:{msg}」,此时下方仍渲染当前 signers(失败时为空数组 → 空态文案)。新建/启停/删除成功的共同点是都会调一次 fetchSigners() 重拉列表——所以这些操作之后看到的表格总是最新的,无需手动刷新。
表格视觉要点:名称列加粗(<strong>)用于快速扫读;指纹列是浅灰底等宽字(cell-code)且默认只显示前 24 位加 …;状态徽标绿灰区分「已启用/已禁用」,一眼看出哪些信任锚在线、哪些被拉黑;最右「操作」列按钮在 busy 期间统一置灰(防止连点时同一行或跨行操作互相覆盖)。整页没有图表与复杂组件,数据量也不大,单屏基本能放下全部白名单条目。
4. 交互元素
4.1 页头与操作结果提示条
| 控件 | 位置 | 含义 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|
| 页面标题 | 页头 h2 | 页面身份 | 纯展示 | 无 | 文案逐字为 Apollo 签名者白名单 |
| 「新建签名者」 | 页头右上(btn-primary) | 打开新建弹窗 | openCreate() 置 createModal.visible=true | 无 | 不重置表单:取消后再次打开,上次未提交内容仍保留 |
| 操作结果提示条 | 页头下方 | 展示最近一次操作结果 | 有 alert.message 才渲染;类型 alert-info/alert-success/alert-error | 无 | 单槽提示:新操作覆盖旧提示,无历史 |
| 提示条「关闭」 | 提示条右侧(link-btn) | 清空提示 | alert.message = '' 立即消失 | 无 | 纯本地状态,不触发请求 |
页面状态由一组 ref 维护:signers(列表数组)、loading(列表加载互斥)、busy(写操作互斥位——新建提交与所有行内操作共用)、alert(提示条单槽)、createModal.visible(弹窗开关)、createForm(新建表单六个字段的 v-model 对象)。loading 只约束列表区三态渲染,busy 只禁用提交按钮与行内操作按钮,二者互不干扰。
4.2 新建弹窗与「启用」勾选框
| 控件 | 位置 | 含义 | 默认/可用条件 | 操作效果 | 边界与细节 |
|---|---|---|---|---|---|
| 弹窗 | modal-overlay + modal-card | 新建表单容器 | createModal.visible=true 时出现 | 遮罩 @click.self 或「取消」/「关闭」可收起 | 关闭不重置表单 |
| 弹窗标题 | modal-header | 新建签名者(strong) | 常驻 | 纯展示 | 右上「关闭」link-btn 同收起 |
| 「启用(加入白名单信任)」 | 表单底部 row-check | 是否加入白名单信任 | 默认勾选(createForm.enabled 初始 true) | 勾选 → 提交 enabled:true;取消勾选 → 提交 enabled:false(见 5.4 默认值坑) | 标签逐字为 启用(加入白名单信任) |
| 「创建签名者」 | 弹窗底部(btn-primary,type=submit) | 提交新建 | busy 时禁用并显示 创建中... | 见 4.3 | 名称/指纹任一为空时前端先拦截 |
| 「取消」 | 弹窗底部(btn-outline) | 收起弹窗不提交 | 始终可用 | createModal.visible=false | 不重置表单 |
4.3 新建表单字段与提交
表单字段与交互(源码 handleCreate 校验后组装 payload 调 POST /signers):
| 字段/控件 | 标签(逐字) | 类型 | 校验与边界 | 提交后的去向 |
|---|---|---|---|---|
| 名称 | 名称(name)* | input | 必填;placeholder 如 demo-signer;与已有记录 name 全局唯一(冲突后端 409) | payload.name |
| 公钥指纹 | 公钥指纹(public_key_fingerprint)* | input | 必填;placeholder 如 9f86d081884c7d65...;全局唯一;下方 form-hint 逐字为「白名单唯一索引,bundle 验签时按指纹匹配信任锚。」 | payload.public_key_fingerprint |
| 公钥 | 公钥(public_key,选填) | textarea(rows=3) | 选填;placeholder PEM 公钥内容(可选);trim 后为空则不携带该字段 | payload.public_key |
| 描述 | 描述(description,选填) | input | 选填;placeholder 签名者用途说明;trim 后为空则不携带 | payload.description |
| 启用 | 启用(加入白名单信任) | checkbox | 默认勾选 | payload.enabled |
提交前的两条校验路径:① 前端 if (!name.trim() || !fingerprint.trim()) 弹 name/public_key_fingerprint 必填;② 后端同名/同指纹返回 409(见 5.4 冲突预检)。前端拦截先于请求,后端拦截兜底。
新建请求体(handleCreate 组装 payload 的最终形态):
{
"name": "demo-signer",
"public_key_fingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"enabled": true,
"public_key": "04f4b1a4...",
"description": "供应链下游签名者"
}
成功时后端返回 {id},页面提示 签名者已创建 id={id}(success),自动刷新列表并把表单重置为初始值(enabled 回到勾选态)。失败提示 创建失败:{msg}(error)。注意:公钥/描述留空时从 payload 里剔除(源码 if (value.trim()) payload[key]=...),后端按零值落空。
4.4 行内操作:启用 / 禁用 / 删除
列表每行的「操作」列按 s.enabled 二选一渲染「禁用」或「启用」,外加常驻「删除」:
| 按钮 | 出现条件 | 触发调用 | 结果提示(逐字) | 边界与细节 |
|---|---|---|---|---|
| 「禁用」 | s.enabled 为真 | POST /signers/{id}/disable | 签名者 "demo-signer" 已禁用 | 禁用即拉黑:该签名者签的 bundle 验签立即 403 |
| 「启用」 | s.enabled 为假 | POST /signers/{id}/enable | 签名者 "demo-signer" 已启用 | 恢复白名单信任;后续重新验签恢复通过 |
| 「删除」 | 恒显示 | DELETE /signers/{id} | 签名者已删除 | 点前先 confirm,确认文案见下 |
删除确认:点「删除」先弹浏览器原生 confirm,文案逐字为「确定从白名单删除签名者 "{name}" 吗?删除后其签名的 bundle 将无法通过验签。」——取消则不调用;确认后执行删除,成功提示 签名者已删除,随后自动刷新列表。
三种写操作全部走 busy 互斥:请求期间按钮禁用(创建中... 只在提交按钮上出现,行内按钮禁用时仅置灰),失败统一提示 操作失败:{msg} / 删除失败:{msg}。启用/禁用成功后不重置列表(直接 fetchSigners() 重拉);删除后同样重拉列表。
4.5 列表展示细节:指纹截断与创建时间
| 列 | 取值 | 渲染 | 边界 |
|---|---|---|---|
| ID | s.id | 纯文本 | 后端自增主键 |
| 名称 | s.name | <strong> 加粗 | 唯一索引 |
| 公钥指纹 | s.public_key_fingerprint | <code class="cell-code"> | shortFp():长度大于 24 取前 24 位 + …;完整指纹放在单元格 title 上悬停查看;空值显示 - |
| 状态 | s.enabled | status-badge + class status-enabled/status-disabled | 文案 已启用(绿)/已禁用(灰) |
| 创建时间 | s.created_at | formatTime():replace('T',' ').replace('Z','') 后截前 19 位 | 如 2026-09-07 05:00:00(UTC 串,不转本地时区);空值显示 - |
列表列的指纹刻意截断(防视觉噪声 + 宽列),需要完整 64 位指纹时悬停即可,或直接调 GET /signers 用接口原文核对。短指纹算法 shortFp:fp.length > 24 ? fp.slice(0,24) + '…' : fp——与制品页 shortDigest 各自独立,展示口径不完全一致(制品页截 16 位,本页截 24 位)。
梳理整页操作的数据流与互斥规则,方便对照源码走读:页面挂载即 fetchSigners()(置 loading=true → GET → signers.value = data || [] → 置 loading=false);新建走「打开弹窗 → 填表 → 校验 → busy=true → POST → 成功提示 + 关弹窗 + 重置表单 + 刷新列表 → busy=false」;行内启停/删除走「busy=true → POST/DELETE → 成功提示 + 刷新列表 → busy=false」。busy 是页面级单例互斥位,不是行级——所以一次只允许一个写操作在途,按钮全部置灰防止连点;loading 与 busy 互不影响(写操作在途时用户仍能看到列表内容)。所有失败路径都只更新 alert、不清空列表数据,因此一次失败的删除不会让页面「丢行」。
弹窗还有一个容易被忽视的交互细节:打开弹窗不重置表单,只有新建成功后或手动清空才会复原。若上一次填写被「取消」打断,再次点「新建签名者」看到的仍是上次的半成品内容(含勾选状态);想干净地开始可以手动清空字段。这在小团队高频录入场景下可能是双刃剑——复用时省事,但也可能把上次的指纹误提交成新条目(提交前校验与后端 409 冲突预检能兜住一部分,见 5.4)。
5. 后端关联
5.1 API 客户端
action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、请求拦截器对所有请求附加 Authorization: Bearer <token>(token 仅取 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 code===0 时把 response.data 解包为业务数据(页面 const { data } = ... 直接得到数组或对象);HTTP 2xx 但 code!==0 拒绝;4xx/5xx 提取 body.message || body.error 并把 message 别名写入 error.response.data.error 后 reject——**401 清 token 并跳 /apollo/login(登录页自身 401 不跳,避免死循环),403 alert('无权限执行该操作')**。页面各 catch 统一以 err.response?.data?.message || err.response?.data?.error || err.message || '未知错误' 取文案展示。
5.2 端点表
| 方法 | 路径 | 权限 | 说明 | 页面触发点 |
|---|---|---|---|---|
| GET | /signers | signer:read | 列出全部白名单(id 升序);可选 ?environment_id=N 按环境过滤(F6,页面不传) | 加载列表 |
| POST | /signers | signer:write | 新建;body {name, public_key_fingerprint, public_key?, description?, enabled?} | 「创建签名者」 |
| PUT | /signers/:id | signer:write | 更新条目(后端已实现,页面无编辑 UI) | 无(API 直调) |
| POST | /signers/:id/enable | signer:write | 启用(恢复白名单信任) | 行内「启用」 |
| POST | /signers/:id/disable | signer:write | 禁用(白名单外签名拒绝应用) | 行内「禁用」 |
| DELETE | /signers/:id | signer:write | 删除白名单条目 | 行内「删除」 |
注册位置:action/products/apollo/server/server.go(protected 组,768-773 行),全部经 RequirePerm 校验。注意 signer:read/signer:write 权限点定义于 action/products/apollo/access/permissions.go(第 21-22 行,值逐字 signer:read/signer:write),与角色/API Key 的权限清单一一对应。HTTP 审计中间件(http_audit.go)对这些写操作全量记录,resource_type 落为 signers、resource_id 取路径 :id——在「审计日志」页可按 signers 检索本页操作。
PUT /signers/:id 的更新语义与新建对齐:body 仍是 signerRequest,但所有字段缺省即保持原值(name/fingerprint/public_key/description 只在非空时覆盖,enabled 只在显式给布尔时覆盖,environment_id 只在显式提供时覆盖归属);改名/改指纹同样先做排除自身的冲突预检再落库,撞唯一索引仍是 409。由于启用/禁用两个专用端点已覆盖 enabled 变更、页面也没有编辑表单,PUT 目前的实际价值主要是给 API 消费者提供「补录环境归属」或「修改描述/公钥」的通道——注意按字段语义它无法把环境级条目的 environment_id 清回全局(需要传 0 或删除重建,注释口径是「清回全局需 DELETE 重建或显式传 0」)。
5.3 响应结构示例
**GET /signers(envelope 解包后为数组,单项即 TrustedSigner 行)**:
{
"id": 1,
"name": "demo-signer",
"public_key_fingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"public_key": "04f4b1a4...(完整 SM2 公钥 hex,较长此处省略)",
"description": "演示签名者(服务器首次构建 bundle 时自动注册,G1 信任锚)",
"enabled": true,
"created_at": "2026-09-07T05:00:00+08:00",
"updated_at": "2026-09-07T05:00:00+08:00"
}
字段含义(hub.TrustedSigner,hub/models.go 264-274 行):id 自增主键;name 唯一索引(size 64);public_key_fingerprint 唯一索引(size 128),即 SM3(公钥字节) 的 hex——白名单匹配键;public_key 为 SM2 公钥 hex(验签用,omitempty,留空的新建条目该字段不出现);description 描述(无 omitempty,空则 "");enabled 信任开关(布尔,无 omitempty);environment_id 环境级归属(omitempty,全局条目不出现);created_at/updated_at 自动时间戳。
读这条示例时注意两个「看起来缺字段」的点:一是这是已升级后的 demo-signer 形态——种子阶段它的指纹是 demo-fingerprint-placeholder、没有 public_key,首次构建 bundle 后才变成上面的样子(过程见 5.4);二是列表接口返回的原始数组可能同时含全局条目(无 environment_id 字段)与环境级条目(带数字 environment_id),但页面表格只取六个展示列,环境归属字段在前端被丢弃,所以页面看不出 scope 差异。需要确认某条归属时以接口原文为准。
**POST /signers(201,envelope 解包后)**:
{ "id": 7 }
**POST /signers/:id/enable / disable(envelope 解包后)**:
{ "id": 7, "enabled": true }
**DELETE /signers/:id(envelope 解包后)**:
{ "id": 7, "deleted": true }
重复 name/指纹的 409 冲突响应(envelope,页面提示取 message):
{
"code": 40901,
"message": "签名者 \"demo-signer\" 已存在",
"request_id": "req_xxx"
}
指纹冲突时 message 为 签名者公钥指纹 "9f86..." 已存在。对不存在的 id 做删除/启停返回 404(message 形如 signer 7 not found),参数解析失败返回 400(invalid signer id/invalid environment_id)。另外,新建请求体里 name/指纹均为空字符串 trim 后为空的,后端返回 400 name/public_key_fingerprint 不能为空(envelope code 40001)——正常到不了这一步,前端必填校验已先拦。
5.4 关键机制
G1 信任锚与四步验签链(跨页联动「制品与渠道」)。hub/VerifyBundle(hub/bundle.go 282-340 行)按序执行:① 逐文件重算 SM3 校验和对照 manifest;② 重算 digest 对照 manifest.Digest;③ 用 signer_fingerprint 在 trusted_signers 查公钥(enabled=true);④ 用白名单公钥对 digest 做 SM2 验签。第 ③ 步由本页管理的数据决定:白名单无此指纹或条目被禁用 → Trusted=false 并返回 403 VERIFY_SIGNER_NOT_TRUSTED,message 逐字为 签名者指纹 "xxx" 不在可信白名单(enabled=true)中,拒绝应用。密码学上合法的签名也会被这一层拦下——白名单的「存在性 + enabled」是信任的充分必要条件。
指纹 = 匹配键、enabled = 信任开关。SignerFingerprint(hub/bundle.go 121-128 行)计算规则:对公钥 hex 解码成字节再做 SM3,取 hex 摘要;白名单存的就是这个值。bundle manifest 的 signer_fingerprint 与白名单条目指纹做等值匹配(不是按 name)。enabled=false 的条目即使指纹命中也会在第 ③ 步被拒——「禁用」比「删除」轻,但效果一致:都让新验签失败。已通过的旧验签结果是否缓存由验签调用方决定,页面本身不落缓存(见第 7 章第 3 问)。
F6 环境级白名单解析(部署目标环境 scope)。GetByFingerprintScoped(hub/repository.go 571-583 行)对某环境的目标做两步解析:先查 environment_id = 目标环境 的条目,命中即返回(此时即使该条目被禁用也不再回退全局——防「环境级拉黑却绕道全局放行」);环境级未命中才回退全局条目(environment_id IS NULL)。VerifyBundleScoped 在部署链路上按部署目标环境走这个解析;VerifyBundle(environmentID=0)只查全局。本页没有环境概念:表单建不出环境级条目,列表也不展示 environment_id;环境级条目只能 API 直调(PUT/POST 带 environment_id)创建,创建后混在本页列表里同样能被启用/禁用/删除(操作按 id 全表命中,不看归属环境)。
name/指纹全局唯一 + 冲突预检。trusted_signers 的 name 与 public_key_fingerprint 都是全局唯一索引(跨全局/环境级 scope 均唯一)。直接 Create 撞索引会落 gorm 裸错误变 500,所以 handleCreateSigner/handleUpdateSigner 先做存在性预检(GetByName、GetByFingerprintAny 查任意 scope),命中即返回 409(ErrCodeSignerConflict,码字 SIGNER_CONFLICT,页面展示中文 message)——把「撞唯一索引」从裸 500 转成可读的 409。这也是演示环境里无法录入两个同名或同指纹签名者的原因。
指纹与公钥的「弱绑定」边界。白名单把 public_key_fingerprint 当匹配键、把 public_key 当验签公钥,但创建/更新接口都不校验「指纹是否等于 SM3(该公钥)」、也不校验公钥格式——录入时二者可以完全不配套。验签走到第 ③ 步时按指纹命中条目,第 ④ 步用该条目的 public_key 做 SM2 验签:若公钥与真实签名密钥对不上,SignatureOK=false。所以在演示环境里,想让「外部签名者」真正可用,录入的指纹必须是目标 bundle manifest 的 signer_fingerprint、公钥必须是那把真实公钥的 hex——两者一起配套才构成有效的信任锚。反过来说,这也是演示的天然边界:平台内所有 bundle 都由 demo-signer 私钥签发(服务端构建),外部密钥指纹无法在演示闭环内真正生效,F6 环境级白名单主要用于模拟「某环境只信自己链上签发方」的分域场景(部署时 VerifyBundleScoped 按目标环境优先解析)。
enabled 默认值坑与按列修正。模型 Enabled bool gorm:"default:true":GORM 建表时该列带 DEFAULT true,Create 用结构体插入时零值 false 会被省略、数据库落成默认 true(SQLite RETURNING 还会把默认值回填内存结构体,随后 Save 仍写 true)。因此 handleCreateSigner 在建完行后对 !enabled 特判:绕过结构体直接 Model(...).Where("id=?").Update("enabled", false) 按列修正(handlers.go 733-739 行)。用户在弹窗里取消勾选「启用」,最终也能得到正确的禁用态,不会因为默认值坑而「取消勾选无效」。
demo-signer 种子占位与首次构建升级。bootstrap 种子(server/seed.go 85-101 行)在空库时以 name=demo-signer、public_key_fingerprint="demo-fingerprint-placeholder"、描述「演示签名者示例(占位指纹;首次构建 bundle 时自动注册真实 SM2 密钥指纹,G1)」、enabled=true 创建演示条目——占位指纹不匹配任何真实密钥,单独存在时验签任何 bundle 都会失败。首次在制品页构建 bundle(signerID 缺省也兜底成 demo-signer)时,bundleStore.ensureSigner(server/bundle_store.go 74-101 行)惰性生成 SM2 密钥对并以 name 冲突键 upsert 白名单:指纹升级为真实 SM3(公钥)、写入 public_key、描述改为「演示签名者(服务器首次构建 bundle 时自动注册,G1 信任锚)」、enabled=true。此后同一服务进程内构建的 bundle 用同一把内存私钥签名,验签按新指纹查白名单即命中。私钥只在内存,服务重启后重新生成(指纹随之变化,历史 bundle 若由旧指纹签发将不再可信——见第 8 章)。
操作审计覆盖。本页全部请求(列表/新建/启停/删除)都过 HTTP 审计中间件,落 apollo_audit_logs 表,在「审计日志」页可按 resource_type=signers、方法/路径检索;写操作(POST/PUT/DELETE)带 :id 时 resource_id 取路径 id,action 形如 POST /api/v1/signers/7/disable。列表只读 GET 同样记录(方法与路径)。
6. 权限与安全
- 认证分层:全部端点位于 protected 组,JWT 无效一律 401;前端路由守卫拦截未登录访问。独立登录体系(
apollo_token),与 AIP 用户库分离。 - 权限点:列表
signer:read,新建/启停/删除signer:write,由后端RequirePerm逐端点校验——不是前端按钮显隐那套,即使绕过前端直调 API 也会被拦。 - 写操作防护:删除前
confirm二次确认并明示「删除后其签名的 bundle 将无法通过验签」;所有写操作期间busy置灰防重复提交。 - 信任即安全语义:白名单是信任根,不是展示数据。误删/误禁某签名者会让该签名者签发的所有 bundle 应用链路立即断掉(验签 403);演示环境尤其注意
demo-signer被删后要重建并重新构建 bundle 才能恢复自洽。 - 指纹与公钥绑定:
public_key_fingerprint全局唯一,页面无编辑入口,错误录入只能删除重建(名称与指纹都不可复用已有历史值)。 - 审计留痕:本页增删启停均入 HTTP 审计,管理员可追溯谁在何时把哪把信任锚禁用/删除。
权限模型的落地要略作展开:RequirePerm 是后端逐路由的硬校验,与「角色管理」里角色绑定的权限集合对照——演示环境预置的管理员角色默认含全部 signer:*,普通「开发者/只读」角色通常只有 signer:read 或全无。若用户登录后页面一片空且无新建按钮反应,多半是权限不足而非数据为空(空态文案 vs 403 alert 是两种完全不同的现象,见第 7 章第 6 问)。此外本页写操作不设独立审批流:谁有 signer:write 谁就能直接禁/删任意条目——信任锚变更属于高风险动作,生产环境建议把 signer:write 只授给专职安全管理员,并配合审计日志留痕做事后追溯。
7. 常见问题与排错
以下问题均基于 SignersPage.vue 的 catch 分支提示、后端 handler/仓储代码与既有测试观察归纳,可按步骤复现。页面所有操作结果都经提示条 alert 呈现(失败为 alert-error 红色、成功为 alert-success 绿色),无独立日志面板;需要更深线索时到后端 apollo_audit_logs 审计日志或服务端日志里查对应请求。
1. 现象:新建报「签名者 "xxx" 已存在」或「签名者公钥指纹 "xxx" 已存在」。 原因:name 或指纹与已有记录冲突(全局唯一索引 + 后端预检转 409)。处理:换名称;用完整指纹核对是否已录入过(列表悬停或查 GET /signers)。注意全局/环境级 scope 共用唯一索引,API 建过环境级同指纹条目也会冲突。
2. 现象:勾掉了「启用」创建,列表里状态还是「已启用」。 原因分两种:创建成功后才检查列表(若提交时勾选被重置可能是表单状态),或者用了旧版后端(未做按列修正)。处理:当前版本后端对 enabled=false 走按列 Update 修正,正常应得到禁用态;若仍异常,确认表单勾选框状态后手动点一次「禁用」,并核对后端版本。
3. 现象:删除/禁用签名者后,对应的 bundle 验签还是通过。 原因:验签信任锚按指纹匹配 enabled=true 条目,删除/禁用只影响之后发起的验签;若验签方(制品页「验签」按钮每次实时调后端,通常无缓存)或中间产物有缓存/下载副本,可能沿用旧结果。处理:在制品页对同一 bundle 重新点一次「验签」确认返回 403 VERIFY_SIGNER_NOT_TRUSTED;仍通过则核对是否命中另一个环境级条目(F6 解析环境级优先)。
4. 现象:列表里指纹显示不全。 原因:页面按 shortFp 只展示前 24 位 + …,是展示层截断。处理:鼠标悬停该单元格看 title 完整指纹;或调 GET /signers 用接口原文核对。
5. 现象:新建时粘贴的长指纹被提示重复,但列表里明明没有。 原因:指纹全局唯一包含 API 直建的环境级条目(页面列表无环境归属列,肉眼不可区分),或指纹含前后空格/不同大小写导致与某条近似但不重复的录入。处理:查 GET /signers 全量数据核对原文;删除冲突条目或用新指纹录入。
6. 现象:页面报「无权限执行该操作」(alert)。 原因:当前角色缺 signer:read/signer:write 权限点,后端 403。处理:到「角色管理」/「API Key 管理」为当前身份授予对应权限;列表与写操作权限点不同,可能「能看不能写」。
7. 现象:创建提交后弹「创建失败:name/public_key_fingerprint 不能为空」。 原因:两个必填字段 trim 后为空(前端 required 与 if(!trim()) 已拦,正常到不了后端;直达 API 或浏览器绕过 required 时由后端兜底)。处理:名称与指纹不要只填空格。
8. **现象:bootstrap 后列表能看到 demo-signer,但它的指纹是占位的,任何验签都 403。** 原因:种子创建的是 demo-fingerprint-placeholder 占位条目,必须等首次构建 bundle 时由 ensureSigner 升级为真实密钥指纹。处理:到制品页构建一次 bundle(signer 缺省即 demo-signer),回来看列表该行指纹与描述已更新;此后验签自洽。
9. 现象:想让某个外部签名者可信,在页面新建了条目但验签仍 403。 原因:验签第 ④ 步用条目里的 public_key 对该 bundle 的签名做 SM2 验签——若条目的指纹不等于 bundle manifest 的 signer_fingerprint、或公钥与真实签名密钥不匹配,信任锚或验签都会失败。处理:录入的指纹必须是该 bundle signer_fingerprint 的等值、公钥必须是该签名者的真实 SM2 公钥 hex,且条目 enabled=true。
10. **现象:删除 demo-signer 后又构建了 bundle,列表出现同名的 demo-signer 新条目。** 原因:每次首次构建 bundle 都会 ensureSigner 幂等重建 demo-signer(name 冲突键 upsert)。处理:属预期行为;注意新条目的指纹是新的(服务进程内密钥重启后也会变),旧 bundle 由旧指纹签发将不再可信。
11. 现象:新建条目里贴了 PEM 公钥文本,之后相关 bundle 验签失败。 原因:页面 placeholder 写的是「PEM 公钥内容(可选)」,但后端 public_key 按 SM2 公钥 hex 存储并在验签时 hex 解码(VerifySignature(pubKeyHex,...)),PEM 文本既不等于指纹对应的原始字节、也无法 hex 解码。处理:录入公钥前先把 PEM 转成 SM2 公钥 hex;若只是想让「已由 demo-signer 签发的 bundle」通过验签,直接沿用 demo-signer 条目即可,不必新建。
12. 现象:写操作全部报「操作失败」,但列表能正常打开。 原因:多半是当前账号只有 signer:read 没有 signer:write(403),或 JWT 已过期(401 会触发跳登录)。处理:到「角色管理」确认身份权限;重新登录后再试;两种情况的错误提示分别来自拦截器的 403 alert 与页面 catch 的 message。
8. 已知缺陷与边界
| 缺陷/边界 | 说明 |
|---|---|
| 无编辑入口 | 页面仅新建/启停/删除;后端 PUT /signers/:id 已实现但前端无任何编辑表单,改名字/指纹/公钥/描述只能删除重建 |
| 环境级归属未暴露 | 表单无 environment_id 字段,页面只能建全局条目;F6 环境级条目仅能 API 直建 |
| 列表不区分 scope | GET /signers 默认返回全局 + 各环境全部条目且无环境归属列,混在一起无法肉眼区分;?environment_id= 过滤参数页面未提供 |
| 指纹截断显示 | 列表只展示前 24 位 + …,完整指纹需悬停或查接口;无「复制完整指纹」快捷操作 |
| 删除即除名 | 删除无撤销;误删需重建,且 name/指纹全局唯一历史不可复用同名同指纹 |
| 占位指纹误导 | 种子 demo-signer 初始为 demo-fingerprint-placeholder,此时验签必 403;不熟悉自举机制会误判为故障 |
| 演示密钥不持久 | demo-signer 私钥只在进程内存,服务重启后重新生成(指纹随之改变),旧指纹签发的 bundle 失效——需重新构建才能自洽 |
| 公钥格式无校验 | 后端新建/更新仍不校验 public_key 的格式与「指纹是否等于 SM3(该公钥)」,录入错值条目将永远验签失败;前端已按后端 hex 口径做输入校验(FP_HEX64/HEX_RE,见下),但服务端缺校验——属后端契约边界 |
| placeholder 文案与后端不一致 | 已修复(提交 412e9c48):公钥 textarea placeholder 改为「SM2 公钥 Hex(十六进制,可选);勿粘贴 PEM 文本」(SignersPage.vue:49)并加 form-hint「后端按 SM2 公钥 hex 验签,粘贴 PEM/DER 文本将导致验签失败」(:50);提交前校验公钥须为偶数长度 hex、指纹须为 64 位 hex(FP_HEX64/HEX_RE,:164-165/237-247),与后端 VerifySignature(pubKeyHex,...) 口径一致 |
| 未建条目即全不可信 | 白名单为空时任何 bundle(含本地构建的)都无法通过验签;演示环境靠 seed + 首次构建自动登记兜底 |
| 无分页/无刷新/无轮询 | 列表一次性返回全量(id 升序);fetchSigners 只在 onMounted 与写操作成功后触发,他人/其他入口改动白名单不会自动反映,无手动刷新按钮 |
| 创建时间时区 | created_at 按 UTC 串截断展示,不转浏览器本地时区,跨时区查看存在时差 |
| 弹窗不随关闭重置 | openCreate()/「取消」都不重置 createForm,上次半成品内容(含勾选状态)会保留到下次打开——误复用上次指纹提交可能落入 409 冲突,需手动清空 |
| 无并发写保护 | 启停/删除按「读-改-写」全量覆盖,无乐观锁/版本号:两个管理端同时操作同一条时后写覆盖先写,极端场景会丢一次意图 |
| 名称/指纹大小写敏感 | name 与指纹唯一索引按原值比对,大小写不同的两个名称视为不同记录,易造成「看起来同名却建出两条」 |
附录 A:速查——页面文案与关键常量清单
| 场景 | 源码取值/文案(逐字) |
|---|---|
| 页面标题 | Apollo 签名者白名单 |
| 新建按钮 | 新建签名者 |
| 弹窗标题 | 新建签名者 |
| 表单标签 | 名称(name)* / 公钥指纹(public_key_fingerprint)* / 公钥(public_key,选填) / 描述(description,选填) |
| 指纹输入框提示 | 如 9f86d081884c7d65... |
| 指纹 form-hint | 白名单唯一索引,bundle 验签时按指纹匹配信任锚。 |
| 公钥 placeholder | PEM 公钥内容(可选) |
| 描述 placeholder | 签名者用途说明 |
| 勾选标签 | 启用(加入白名单信任) |
| 提交按钮 | 创建签名者 / busy 时 创建中... |
| 弹窗按钮 | 关闭 / 取消 |
| 列表空态 | 暂无签名者,点击"新建签名者"加入白名单。 |
| 加载中文案 | 加载中... |
| 状态徽标 | 已启用(绿)/ 已禁用(灰) |
| 行内按钮 | 禁用 / 启用 / 删除 |
| 删除确认 | 确定从白名单删除签名者 "{name}" 吗?删除后其签名的 bundle 将无法通过验签。 |
| 成功提示 | 签名者已创建 id={id} / 签名者 "demo-signer" 已启用 / 签名者 "demo-signer" 已禁用 / 签名者已删除 |
| 失败提示 | 加载签名者列表失败:{msg} / 创建失败:{msg} / 操作失败:{msg} / 删除失败:{msg} |
| 前端必填提示 | name/public_key_fingerprint 必填 |
| 后端必填提示 | name/public_key_fingerprint 不能为空 |
| 后端冲突 409 | 码 40901,message 签名者 "{name}" 已存在 / 签名者公钥指纹 "{fp}" 已存在 |
| 后端 404 | signer {id} not found |
| 验签信任锚失败 | 403 + 码字 VERIFY_SIGNER_NOT_TRUSTED,message 签名者指纹 "{fp}" 不在可信白名单(enabled=true)中,拒绝应用 |
| 权限点 | signer:read(列表)/ signer:write(写操作) |
| 存储表 | trusted_signers |
| 指纹算法 | SM3(公钥字节 hex) 摘要(64 位 hex) |
| seed 占位指纹 | demo-fingerprint-placeholder |
| 短指纹展示 | shortFp:长度 > 24 取前 24 位 + … |