> 所属产品:LightApollo · 信息截止:2026-09-07(deep-rev)
1. 页面概览
1.1 是什么
「角色管理」页(对应前端源码 action/web/src/views/apollo/RolesPage.vue,页面标题「平台管理 · 角色管理」)是 LightApollo 细粒度 RBAC(TAD-11)的角色与权限点定义入口:页面上半部分是带关键字过滤与客户端分页的角色列表,把每个角色的角色名与权限点标签铺成一览表;两个操作入口——页头「新建角色」弹窗以预置权限点多选网格方式定义新角色(13 个权限分组 + 超级权限 *:*),每行「查看详情」弹窗展示该角色经服务端 Preload 后的全部权限点。它与「用户管理」页成对出现:本页负责「定义谁(角色)能做什么(权限点)」,用户管理页负责「把角色挂到哪些账号上」,两侧都只开放「读与建」——角色一经创建不可改名、不可改权限、不可删除(后端未注册 PUT/DELETE /roles/:id),这是理解本页所有交互的边界前提。
页面背后的权限模型是 resource:action 风格的动作级权限点(如 user:read、deployment:execute):后端 access.Authorize 在每次受保护请求时,取当前主体全部角色的 role_permissions(类型 ACTION_ACCESS,resource_name 即权限点)做并集,再与接口要求做 glob 比对(PermissionMatch,见 5.4)——集合里存在与目标完全相等的权限点、resource:* 资源前缀通配、或全集 *:* 即放行。因此本页创建的每个角色本质是「一组权限点的命名集合」;勾 *:* 的角色等于拿到全量接口权限(超管语义),页面在网格末尾单独提供「超级权限」分组并注文「通配全部权限」。首次部署时 seed 幂等建好 5 个内置角色(platform_admin/project_admin 持 *:*,developer/operator/viewer 持各业务资源权限点),并把 admin 用户绑定 platform_admin——本页创建的自定义角色可补足内置角色之外的分工(例如「只读安全合规专员」「仅可发部署的运维」)。
从「输入→输出」看:输入是管理员填写的新角色名与勾选的权限点集合;输出是 POST /roles 对 roles 表(角色名唯一)+ role_permissions 表(逐条写权限点)的写操作,以及 GET /roles/GET /roles/:id 的读操作,页面提示条给出成败反馈。本页写操作只此一个(新建),且全程受保护:GET /roles 需要 role:read,POST /roles 需要 role:write。新建角色与分配角色(用户页的 role:write 路径)都落审计,创建者与时间可在审计日志页反查。
典型使用链路(演示视角):① 超管 admin 登录进入本页,看到 seed 建好的 5 个内置角色(platform_admin 行权限点列只有 *:* 一个标签,developer 行显示前 6 个 + 「+26 个」);② 要给"安全合规专员"最小权限,点「新建角色」填 compliance_reader,勾 user:read/role:read/apikey:read/project:read/audit:read 加各业务资源 read(这些单点都在网格内)后点「创建」,提示 角色 "compliance_reader" 创建成功;③ 行内「查看详情」核实权限点数量与标签无误;④ 到「用户管理」页把 compliance_reader 挂到目标账号;⑤ 该账号此后登录即拥有只读面(读用户/角色/Key/项目/审计与各资源),但没有任何 write/execute,也看不到未授资源(如部署编排数据)。想扩大或收缩能力只能再建角色并重绑——角色本身的不可变性把"改权限"变成"换角色"操作。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| 角色一览 | 全量角色按 role_name 升序展示角色名与权限点标签(每行最多平铺前 6 个,超出显示「+N 个」) | 页面主体角色列表 |
| 关键字过滤 | 客户端按角色名做不区分大小写的包含匹配,输入即回到第 1 页 | 搜索框(placeholder「搜索角色名」) |
| 客户端分页 | 每页 10 条本地切片翻页 | 「上一页」/「下一页」 |
| 新建角色 | 以 13 组预置权限点网格 + *:* 通配多选定义角色(角色名唯一,重复返回 409) | 页头「新建角色」按钮 → 弹窗「创建」 |
| 查看详情 | 弹窗拉取 GET /roles/:id 展示该角色 ID、权限点总数与全部权限点标签 | 行内「查看详情」 |
| 权限点清单内聚 | 页面内置权限点常量,已补全至后端全量 22 资源域 52 权限点(RolesPage.vue:138-162),对齐 permissions.go 的 PermXxx 命名(仍是前端硬编码、属扩展边界,见 8 章) | 「新建角色」弹窗权限分组 |
1.3 一句话总结
本页以「预置权限点分组多选 + *:* 通配」为素材创建 LightApollo 角色,供「用户管理」页把角色挂到账号——只读与建,不改不删。
2. 访问入口
2.1 路由与菜单
| 项 | 值 |
|---|---|
| 路由 path | /apollo/admin/roles |
| 路由 name | ApolloAdminRoles |
| meta.title | Apollo 角色管理 |
| meta.requiresAuth | true |
| 侧边栏位置 | ApolloLayout 侧边栏「平台管理」分组第二项(用户管理之后、API Key 管理之前) |
| 前端源码 | action/web/src/views/apollo/RolesPage.vue |
| API 客户端 | action/web/src/api/apolloClient.js |
| 路由注册 | action/web/src/router/index.js,/apollo 子路由 children 下 488–493 行,component 挂 ApolloLayout |
相邻页(同属平台管理组):用户管理 admin-users.md(前,负责把本页角色挂到账号)、API Key 管理 admin-api-keys.md(后)。角色先在本页建,再到用户页分配。
2.2 认证与权限
- 路由
requiresAuth: true;登录态为独立apollo_token(Bearer JWT),2026-09-12 修订后不再回退读取旧aip_token。401 由拦截器清 token 并跳/apollo/login(登录页自身 401 不跳转)。 - 列表与详情读操作需要
role:read;「新建角色」需要role:write(后端统一RequirePerm中间件鉴权)。403 时拦截器先alert('无权限执行该操作'),随后页面 catch 拼「加载角色列表失败:无权限执行该操作」这类提示。 - 内置 5 角色中仅
platform_admin/project_admin(持*:*)默认具备 role 相关权限点——developer/operator/viewer的 seed 权限清单不含role:*,即角色管理页默认只对两类超管开放。 - 把"查看角色"读权下放给业务角色要绕一圈:新建一个只含
role:read的自定义角色本身就需要role:write(创建者须已是超管或已持role:write);下放对象拿到role:read后只能看列表/详情,仍无role:write去建角色或分配角色——读写两个权限点天然分离,可据此做出"只能审阅角色的只读审计员"。另注意:给账号分配/移除角色(用户管理页)挂的也是role:write,想授权"只分配、不新建"目前无更细拆分(见 8 章)。 - 给自定义角色勾上
*:*即获得全量接口权限(超管语义),等同把账号层、部署、审计等所有写路径都交出去,授权前务必确认。
2.3 端口与 API 前缀
- Apollo 后端端口 18082;Vite 代理把
/apollo-api前缀 rewrite 为/api,客户端 baseURL/apollo-api/v1。 - 统一响应 envelope
{code,message,data,request_id}:拦截器在code===0时把response.data解包为业务数据。注意GET /roles的data是裸数组(角色列表),不是用户页那种{items,...}分页结构,前端用Array.isArray(data)判断。
3. 界面布局
页面纵向分四区:页头/提示条、角色列表卡片、两个仅在 visible 为真时才渲染的全屏遮罩弹窗。列表卡片内是「工具栏 + 表格 + 分页」三段;两个弹窗共用同一套遮罩样式,modal-card 宽度 600px(新建:权限分组多时可内滚,容器 overflow-y:auto、max-height:80vh)与 440px(详情,追加类 modal-sm),点遮罩空白处(@click.self)等价于点弹窗右上角「关闭」。交互上同一时刻最多只有一个弹窗可操作——遮罩覆盖整页,弹窗打开状态下点不到列表行、也就无法触发另一个弹窗。
+--------------------------------------------------------------------+
| 平台管理 · 角色管理 [新建角色] |
| [操作结果提示条(v-if alert.message,右侧「关闭」)] |
+--------------------------------------------------------------------+
| ┌ 列表卡片 ──────────────────────────────────────────────────────┐ |
| │ 工具栏:[搜索角色名] 共 N 个角色 │ |
| │ 表格列:ID | 角色名 | 权限点 | 操作 │ |
| │ · 权限点列:perm-tag × 前 6 个 + 「+N 个」;无权限显示「未授权」 │ |
| │ · 操作列:[查看详情] │ |
| │ 空态:加载中... / 暂无角色(可调整关键字) │ |
| │ 分页(totalPages>1 才显示):上一页 | page / totalPages | 下一页 │ |
| └────────────────────────────────────────────────────────────────┘ │
+--------------------------------------------------------------------+
| ┌ 新建角色弹窗 ──────────────────────────────────────────────────┐ │
| │ 角色名(name)* [如 project_admin / developer] │ │
| │ 权限点(permissions,可多选): │ │
| │ 分组标题:期望状态 制品 发布通道 部署策略 签名者 部署编排 │ │
| │ 漂移 Agent 用户 角色 API Key 项目 审计 │ │
| │ 每组 perm-grid 3 列 checkbox(code 显示权限点串) │ │
| │ 超级权限:*:*(通配全部权限) │ │
| │ [取消] [创建/提交中...] │ │
| └────────────────────────────────────────────────────────────────┘ │
| ┌ 角色详情弹窗(modal-sm)───────────────────────────────────────┐ │
| │ 角色详情:{{role_name}} [关闭] │ │
| │ ID:{{id}} · 权限点 {{N}} 个 │ │
| │ perm-tag × N 全部权限点 / 空:「该角色未授予任何权限点。」 │ │
| └────────────────────────────────────────────────────────────────┘ │
+--------------------------------------------------------------------+
各板块职责:
| 板块 | 职责 |
|---|---|
| 页头 + 操作结果提示条 | 页面标题与「新建角色」按钮;全局单槽操作结果提示 |
| 列表卡片 | 工具栏(搜索 + 计数)、角色表格、加载/空态、客户端分页 |
| 新建角色弹窗 | 角色名 + 预置权限点多选网格(22 组 + 超级权限),提交 POST /roles |
| 角色详情弹窗 | 展示 GET /roles/:id 返回的角色 ID、权限点数量与全部权限点标签 |
4. 交互元素
4.1 页头与操作结果提示条
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 页面标题 | 页头 h2 | 页面身份 | 常驻 | 文案「平台管理 · 角色管理」 | 无 | 与源码 h2 逐字一致 |
| 「新建角色」 | 页头(btn-primary) | 打开新建角色弹窗 | 常驻可点 | openCreate() 置弹窗可见并清空表单 | 无 | 权限不足时提交后被 403 |
| 操作结果提示条 | 页头下方 | 最近一次操作结果 | 有 alert.message 才显示 | 三型提示 +「关闭」 | 无 | 单槽覆盖,无历史 |
4.2 「新建角色」按钮与弹窗
新建角色是页面唯一写操作。弹窗结构为角色名 + 权限点多选。
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 角色名(name) | 弹窗首行输入框,placeholder「如 project_admin / developer」 | 角色名(唯一) | 必填(required);提交前 JS 判 !name.trim() 空则本地提示「角色名不能为空」 | 提交时 trim 后随 body {name} 发送 | POST /roles | 后端 trim 判空 400;重复 409「角色已存在」 |
| 权限点(permissions,可多选) | 第二行分组网格 | 勾选的权限点集合 | 可全不勾(创建"零权限"角色) | checkbox v-model 维护 permissions[] | 随 body {permissions} 发送 | 勾选内容直接作为 resource_name 落库;见 4.3 分组明细 |
| 「取消」/「关闭」/遮罩 | 弹窗 | 放弃创建 | 常驻 | visible=false | 无 | 三处等价 |
| 「创建」 | 弹窗底部(btn-primary) | 提交新建 | creating=false 可用;提交中显示「提交中...」 | 成功:提示 角色 "{data.name}" 创建成功、关弹窗、fetchRoles() 刷新 | POST /roles | 失败提示「创建角色失败:{错误}」,弹窗保持打开、勾选保留 |
创建成功后角色即出现在列表中并立即可在「用户管理」页的分配弹窗里被勾选;无需二次激活。
4.3 权限点分组网格明细
页面内置常量 PERM_GROUPS(硬编码于 RolesPage.vue:138-162),共 22 组 + 超级权限 1 组,逐字如下(前 13 组为 AIP/Apollo 早期资源域,后 9 组为阶段 B/C/D 追加、对齐 permissions.go 的 PermXxx):
| 分组标题 | 组内权限点(code 展示串) |
|---|---|
| 期望状态 | desired-state:read、desired-state:write |
| 制品 | bundle:read、bundle:write |
| 发布通道 | channel:read、channel:write |
| 部署策略 | policy:read、policy:write |
| 签名者 | signer:read、signer:write |
| 部署编排 | deployment:read、deployment:execute |
| 漂移 | drift:read、drift:reconcile |
| Agent | agent:read、agent:manage |
| 用户 | user:read、user:write |
| 角色 | role:read、role:write |
| API Key | apikey:read、apikey:write |
| 项目 | project:read、project:write |
| 审计 | audit:read |
| 环境 | env:read、env:write、env:execute |
| Git 仓库 | git-repo:read、git-repo:write、git-repo:execute |
| 同步目标 | sync-target:read、sync-target:write、sync-target:execute |
| 制品仓库 | artifact:read、artifact:write、artifact:execute |
| 流水线 | pipeline:read、pipeline:write、pipeline:execute |
| 监控 | monitoring:read、monitoring:write、monitoring:execute |
| 告警自愈 | alert:read、alert:write、alert:execute |
| 安全合规 | security:read、security:write、security:execute |
| 配置管理 | config:read、config:write、config:execute |
| 超级权限 | *:*(通配全部权限) |
命名对齐 products/apollo/access/permissions.go 的 PermXxx 常量(PermUserRead = "user:read"、PermDeploymentExecute = "deployment:execute" 等)。每组内权限点是各自独立的多选框:勾 user:read 不会自动带 user:write。网格为 3 列布局,权限点串以等宽 code 展示。
勾选与提交的行为细节:每个 checkbox 的 value 即权限点串本身,v-model 维护在 createModal.permissions 数组里——即勾选顺序无意义、提交是整数组一次发送;「超级权限」分组渲染在 13 组之后,勾上 *:* 可与其它具体点并存于同一数组(后端不排斥混搭,落库时 *:* 与具体点都写进 role_permissions,超管直通使具体点形同冗余但无害)。创建进行中只有「创建」按钮被 creating 禁用,输入框与勾选仍可改;提交失败(409 角色已存在等)时弹窗不关闭、已填角色名与已勾权限点原样保留,改完可再点提交,无需重填。弹窗无"全选/清空"快捷操作,权限点多的角色需逐组手勾。
4.4 搜索框与列表表格
| 控件/列 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 搜索框 | 工具栏(placeholder「搜索角色名」) | 按角色名过滤 | 选填 | @input 置 page=1;filteredRoles 对 role_name 小写包含匹配 | 无 | 纯前端 |
| 角色计数 | 工具栏右侧 | 过滤后数量 | 常驻 | 共 {{ filteredRoles.length }} 个角色 | 无 | 是过滤后而非全量 |
| ID | 首列 | 角色主键 | - | 纯展示(自增 uint) | 无 | 列表接口返回完整 id |
| 角色名 | 第二列加粗 | 角色名(排序键) | - | 纯展示 | 无 | 列表按 role_name 升序 |
| 权限点 | 第三列 | 权限点标签组 | permList(role) | 取前 6 个 perm-tag(code 等宽),超过 6 个追加灰字 +N 个;空显示「未授权」 | 无 | permList 只取 permission_type==='ACTION_ACCESS' && resource_name 的 resource_name |
| 「查看详情」 | 第四列(btn-sm btn-outline) | 打开角色详情弹窗 | 常驻 | 见 4.5 | 弹窗打开时 GET /roles/:id | - |
| 空态 | 表格区 | 反馈 | loading→「加载中...」;空→「暂无角色(可调整关键字)」 | 纯提示 | 无 | 空态文案随是否有关键字变化 |
permList 是理解「角色能做什么」的关键:角色对象里的 permissions[] 是 role_permissions 表记录(含 permission_type/resource_name),本页只认 ACTION_ACCESS 类型、取 resource_name 作为权限点展示——*:* 也会作为一个普通权限点标签显示在平台管理员行上。
加载失败与空态的独立呈现。fetchRoles 出错(403/网络异常)时同时把顶部 alert 与列表区的 loadError 置为 error(RolesPage.vue:189-201),列表区渲染独立错误态「加载角色列表失败:{msg}」+「重试」按钮而非空态——典型场景是无 role:read 的账号打开本页:提示条与列表区都明确显示"加载失败/无权限",不再出现"系统里一个角色都没有"的误读。空态只在 loading=false、loadError 为空且过滤后长度为 0 时出现,两者从列表区即可区分(已修复(提交 412e9c48),见 8 章)。
4.5 「查看详情」按钮与角色详情弹窗
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「查看详情」 | 角色行操作列 | 查看该角色全部权限点 | 常驻 | 置弹窗可见并 GET /roles/:id | 打开即 GET /roles/:id | 详情为最新服务端数据(含后续别处改库),与列表行可能有细微差异 |
| 详情标题 | 弹窗头 | 角色名标识 | 常驻 | 角色详情:{{ role_name }} | 无 | 与源码一致 |
| ID/权限点计数 | 弹窗 meta 行 | 标识与规模 | 常驻 | ID:{{id}} · 权限点 {{N}} 个 | 无 | N 为 permissions 数组长度(ACTION_ACCESS 全部) |
| 权限点标签 | 弹窗 body | 全部权限点 | 权限点非空 | 逐条 perm-tag(code)平铺 | 无 | 空则显示灰字「该角色未授予任何权限点。」 |
| 加载/关闭 | 弹窗 | 反馈/关闭 | detailLoading 时显示「加载中...」 | 「关闭」/遮罩关弹窗 | 无 | 加载失败提示「加载角色详情失败:{错误}」,弹窗停留可重开 |
详情数据来自 permList(data):GET /roles/:id 返回单个角色对象,直接对该对象过滤 ACTION_ACCESS 得到权限点数组(响应若是数组形态则前端兜底为空数组)。
4.6 分页控件
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「上一页」/「下一页」 | 分页栏 | 翻页 | page>1 / page<totalPages 才可点 | page-- / page++ | 无 | 纯本地切片 |
| 页码信息 | 分页栏中 | 当前页/总页数 | 常驻 | {{ page }} / {{ totalPages }} | 无 | totalPages=max(1, ceil(N/10)) |
与用户页相同:GET /roles 返回全量,前端按 PAGE_SIZE=10 切片;分页栏仅在超过 1 页时渲染。
5. 后端关联
5.1 API 客户端
action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、请求拦截器统一附 Authorization: Bearer <token>(仅 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 envelope code===0 时把 response.data 解包为业务数据;4xx/5xx 提取 body.message || body.error 写入 error.response.data.error 后 reject——401 清 token 跳登录页,403 alert('无权限执行该操作')。页面 catch 统一以 err.response?.data?.error || err.message 取后端文案。
5.2 端点表
| 方法 | 路径 | 权限点 | 请求体/Query | 页面触发点 |
|---|---|---|---|---|
| GET | /roles | role:read | - | 列表加载(返回裸数组) |
| POST | /roles | role:write | body {name, permissions: [string]} | 「新建角色」弹窗「创建」 |
| GET | /roles/:id | role:read | - | 「查看详情」弹窗 |
路由注册于 action/products/apollo/server/server.go 818–820 行(/api/v1 protected 组)。permissions.go 权限目录另列有 PUT/DELETE /roles/:id(role:write),但 server.go 未注册对应 handler——角色无编辑、删除能力,见 8 章。
5.3 响应结构示例
GET /roles(envelope 解包后为裸数组,每项 model.Role 含 permissions):
[
{
"id": 1,
"role_name": "platform_admin",
"permissions": [
{ "id": 1, "role_id": 1, "permission_type": "ACTION_ACCESS", "resource_name": "*:*", "filter_condition": null }
]
},
{
"id": 2,
"role_name": "developer",
"permissions": [
{ "id": 3, "role_id": 2, "permission_type": "ACTION_ACCESS", "resource_name": "desired-state:read", "filter_condition": null },
{ "id": 4, "role_id": 2, "permission_type": "ACTION_ACCESS", "resource_name": "desired-state:write", "filter_condition": null },
{ "id": 5, "role_id": 2, "permission_type": "ACTION_ACCESS", "resource_name": "deployment:read", "filter_condition": null }
]
}
]
字段含义:Role(platform/model/models.go)——id 自增主键;role_name 唯一(表内无 created_at/updated_at 列,JSON 用 gorm:"-" 忽略,故响应没有时间字段);permissions 为关联的 RolePermission 列表(Preload 加载),每项含 id、role_id、permission_type(ACTION_ACCESS)、resource_name(即权限点,如 desired-state:read)、filter_condition(本页场景恒为 null)。角色 JSON 没有多余嵌套,前端直接读 role_name 与 permissions。
POST /roles(201 Created):
{ "code": 0, "message": "ok", "request_id": "…", "data": { "id": 6, "name": "release_guard" } }
GET /roles/:id(200):data 为单个角色对象(结构同列表项,含 permissions)。
错误示例:
{ "code": 40901, "message": "角色已存在", "request_id": "…" } // HTTP 409,角色名重复
{ "code": 40001, "message": "角色名不能为空", "request_id": "…" } // HTTP 400
{ "code": 40401, "message": "角色 99 不存在", "request_id": "…" } // HTTP 404,GET /roles/99
HTTP 状态码与业务码映射速查:统一 envelope 用业务码表达错误类别——400→40001 参数错误、401→40101 未认证、403→40301 无权限、404→40401 不存在、409→40901 冲突、500→50001 内部错误(映射见 api.go statusToCode)。本页实际会遇到的分支:建角色空名/非法 id 为 40001、无 role:read/role:write 为 40301、详情查不存在的角色为 40401、角色名重复为 40901。前端取值链路:拦截器把 body.message || body.error 转写进 err.response.data.error,页面 catch 再以 err.response?.data?.error || err.message 拼提示——所以「页面提示文案」与「curl 直调看到的 body.message」同源(如 创建角色失败:角色已存在),排查时两边可以互证。
5.4 关键机制
角色的落库方式(createRoleRequest → accessSvc.CreateRole)。POST /roles 先 trim 角色名,空则 400「角色名不能为空」;随后按 role_name 计数判重,重复 409「角色已存在」;通过后 db.Create(&role) 建角色行,再逐条为 permissions 数组里每个非空串创建 RolePermission{RoleID, PermissionType: ActionAccessPermission, ResourceName: perm},最后回查 Preload("Permissions") 返回。需要记住两点:① 勾选的权限点串会原样作为 resource_name 存库,不做合法性白名单校验——理论上可经手写请求写入任意串(含 *:*),页面勾选仅是 UI 便利;② 建角色与写权限点之间没有事务,中途失败会留下「角色行 + 部分权限点」的半成品,且因角色名已占用,同名的重试会撞 409(见 8 章)。
权限点字符串与匹配约定。权限点为 resource:action 二元组(如 user:read、deployment:execute),permissions.go 常量清单共 22 个资源域、52 个权限点。后端鉴权由 access.PermissionMatch(granted, required) 实现 glob 三档匹配:授出一侧(granted)集合里存在与目标 required 完全相等的权限点即命中;存在 resource:* 资源前缀通配即命中(代码在 strings.HasSuffix(g, ":*") 分支把该资源下任意 action 的目标都视为命中,授出 user:* 会同时放行 user:read/user:write);存在全集 *:* 即命中(超管另在 Authorize 内先判 IsSuper 直通,不做逐条扫描)。通配只发生在授出一侧——目标侧永远是具体权限点,角色持有的 user:* 会被展开去匹配该资源下任何接口要求。本页权限网格不提供 resource:* 中间档(要么勾单个具体点、要么勾 *:*),因此经 UI 授权的最小粒度就是单个权限点;但 POST /roles 本身不做权限点白名单,手写请求往 permissions[] 里塞 user:* 这类前缀通配会被原样落库并真实生效——"页面看似最小粒度、接口实际支持前缀通配"是理解本页授权语义的关键(见 8 章)。
内置角色与默认授权面。seed(access/seed.go)幂等创建 5 个内置角色并写入各自 role_permissions,再把 admin 用户绑定到 platform_admin(均 OnConflict DoNothing,重复启动不报错)。5 角色分两类:platform_admin 与 project_admin 是 global/project 级超管,各持 1 个 *:*;developer/operator/viewer 是不含平台管理组权限点的业务角色。在本页列表里它们照常显示(developer 行只平铺前 6 个标签 + 「+26 个」),在「用户管理」页分配弹窗以「N 个权限」展示。构成速查:
| 角色 | 权限点数 | 与网格重叠 | 未进网格 | 平台管理组(user/role/apikey/project/audit) |
|---|---|---|---|---|
platform_admin | 1(*:*) | - | - | 经 *:* 全部覆盖 |
project_admin | 1(*:*) | - | - | 经 *:* 全部覆盖 |
developer | 32 | 13 | 19 | 不含 |
operator | 26 | 10 | 16 | 不含 |
viewer | 17 | 8 | 9 | 不含 |
developer 的 32 点集中在:desired-state/bundle/channel/policy/deployment/drift/agent 的读与写(deployment/drift 为 execute/reconcile)+ env:read + git-repo/sync-target/artifact/pipeline 的读写与 execute + monitoring/alert/security/config 的读与写;operator 在其基础上剔除 write、保留 read/execute,另补 agent:manage、env:execute、pipeline/sync-target/monitoring/alert/security/config 的 execute;viewer 则纯只读(各资源 read),且是三者中唯一含 signer:read 的——operator 反而没有签名者只读,developer 完全没有 signer 域。三者的共同边界是都不含 user/role/apikey/project/audit 平台管理组权限点,即内置业务角色在本页打开就是列表 403(缺 role:read),更谈不上建角色/分配角色——平台管理操作默认只由持 *:* 的超管承担。若 seed 未跑(如直连旧库),本页会空转,先到后端初始化确认 5 角色在列。
四个非超管角色的授权语义。Authorize 对 platform_admin/project_admin 走 IsSuper 直通(有效集合含 *:*);developer/operator/viewer 走 PermissionMatch 逐条 glob。三条推论:① 内置角色不含任何 resource:* 中间档,全是具体点,匹配退化为"并集里找相等项";② 给业务账号叠加多角色时有效集合取并集,授权是加法语义,没有 deny 覆盖机制;③ seed 为 developer 保留了 env:read 与 config/monitoring 等阶段 B~D 权限点,与网格的 13 组不是同一坐标系——对照时以 permissions.go 为基准,不要拿网格当全量清单。
权限点的消费面(与相邻页联动)。本页建的每个权限点会在「用户管理」页分配给账号后立即生效:账号登录后的每次受保护请求(读制品、发部署、看审计等)都按「角色 → 权限点并集 → glob 匹配」判定——被授予的权限点逐个与所需点比较:相等即命中,或授权点为 资源:* 前缀通配 / *:* 全通配也能命中(见 5.4)。修改授权的唯一途径就是重建角色再重分配——因为角色不可改不可删(见 8 章),旧角色即使无人使用也无法清除,只能留在表里(列表可见、不可勾选给新用户以外无副作用)。
授权改动何时生效(请求时回库解析)。登录态(JWT)请求每次经 Authn → Authenticate → resolvePermissions 现查 user_roles → roles → role_permissions,不把权限烧进 token——因此本页新建的角色一旦在「用户管理」页绑定到账号,该账号的下一个请求就按新集合判定,无需重新登录;反向(从账号解绑角色)同样立即失效,甚至不等 token 过期。一个例外是 API Key:Authenticate 对 lap_ 前缀走 ValidateAPIKey,用 Key 自身权限快照(为空才回源继承所属用户角色),是否即时跟随角色变动取决于建 Key 时是否固化过权限——详见 API Key 页,本页登录态交互不受此影响。
一次授权判定的完整链路。以"给运营账号放开部署权、但不给平台管理权"为例:① 本页「新建角色」填 release_guard,勾「部署编排」组 deployment:read + deployment:execute、可再加「漂移」组 drift:read + drift:reconcile;② 到「用户管理」页把 release_guard 绑定给 zhangsan;③ zhangsan 登录后的每次受保护请求依次执行——Authn 解 token → resolvePermissions 取 release_guard 的 4 个权限点为有效集合 → Authorize(非超管,走 PermissionMatch glob):POST /api/v1/deployments/start 命中 execute 放行、GET /api/v1/deployments 命中 read 放行,而 GET /api/v1/audit/logs 因集合里没有 audit:read 返回 HTTP 403,body {"code":40301,"message":"无权限执行该操作","request_id":"…"},页面 catch 拼出「加载审计日志失败:无权限执行该操作」。若 zhangsan 后来又挂上 viewer,有效集合变为两角色并集,判定逻辑不变——多角色只是把并集变大,这也是"一个账号拆多个细粒度角色叠加授权"能成立的原因。
审计。所有请求经根路由组 httpAuditMiddleware 异步落 apollo_audit_logs:创建角色(POST /api/v1/roles)的请求体会被脱敏后再入库(权限点串无敏感键,但统一走脱敏逻辑),action = POST /api/v1/roles、resource_type = roles、2xx 记 success。详情与列表 GET 只读也落审计但结果均为 success,属正常流量。授权动作的可追溯性:谁在何时把 *:* 或某个权限点包进新角色、又把该角色绑定到哪个账号,都能在审计日志页按 POST /api/v1/roles 与 POST /api/v1/users/{id}/roles 两类记录反查——给角色加 *:* 前应预期这一记录会留下。
6. 权限与安全
- 认证分层:页面访问与全部接口要求登录;路由
requiresAuth,token 过期由拦截器统一清态重登。 - 权限点:读
role:read、写role:write;无权限 403。默认仅platform_admin/project_admin(*:*)能进本页,developer/operator/viewer打开即列表 403——想下放需先给某自定义角色授role:read/role:write。 *:*即全部:勾选超级权限会把角色变成超管语义,等同获得所有写路径(含账号/角色管理、部署执行、审计豁免前提下的全量读)。授权*:*的动作与登录主体都记审计,可反查。- 写操作面极窄:页面唯一写操作是新建角色,有
role:write约束与重名校验;角色不可编辑/删除,从结构上避免"改了角色权限连带影响已挂账号"的连锁事故——但副作用是错建的角色无法清理(见 8 章)。 - 无越权数据面:角色是全局定义(不分项目/空间),分配只影响 Apollo 域内账号;接口响应不含密码/令牌类敏感字段。
- 保留角色名即超管标识:
resolvePermissions把持有platform_admin或字面admin角色的账号直接短路为*:*(不要求该角色真有role_permissions行)——所以字面admin是按名提权的高危通道。已修复(提交 412e9c48):前端RESERVED_ROLE_NAMES(6 项)拦截保留名创建(RolesPage.vue:166/230-235),后端CreateRole亦拒绝(access_service.go:277-279),该通道已封堵(见 8 章)。 - 授权是加法、无 deny 覆盖:多角色权限取并集,任何角色都不含"排除项"语义;撤销能力只能靠解绑角色或新建重绑,不存在"除某项外都可"的负向表达——设计最小权限时只能正向枚举要给的权限点。
7. 常见问题与排错
以下均由 RolesPage.vue 的 catch 分支提示与后端 handler/service 代码归纳,可溯源复现。
- 列表空白并提示「加载角色列表失败:无权限执行该操作」:原因是当前账号无
role:read(403)。处理:用platform_admin/project_admin账号,或让超管为账号所在角色补role:read。403 时会同时出现拦截器全局提示与页内失败提示两条,属预期。
- 点「新建角色」提示「创建角色失败:角色已存在」:原因是角色名已被占用(
role_name唯一)。处理:换角色名;注意历史半成品角色(见 8 章)也会占名,需换名重建。
- 角色名为纯空格也能触发但立即提示「角色名不能为空」:原因是前端用
!name.trim()判空(空格为 truthy 不禁用按钮),后端 trim 后同样 400。处理:输入合法角色名;判空逻辑两端都有,属一致兜底。
- 新建成功后列表没有出现新角色:原因是成功提示后
fetchRoles()会全量重拉,若失败或后端返回异常则列表停留旧数据。处理:看提示是否「角色 "{name}" 创建成功」;确认POST /roles返回 201;必要时整页刷新。
- 某角色详情显示的权限点比列表行少/多:原因是列表行的权限标签来自列表接口返回的
permissions,详情弹窗每次打开都重新GET /roles/:id,若两处时间点之间有变更(仅可能来自直连后端/脚本写库)会出现差异。处理:以详情弹窗为准;正常 UI 操作路径下两者一致。
- 想给角色追加一个权限点,找不到编辑按钮:原因是本页与后端都不提供角色编辑/删除(无
PUT/DELETE /roles/:id路由)。处理:新建一个带完整权限点集合的新角色,再到「用户管理」页把旧角色从相关账号移除并分配新角色;旧角色行会保留在列表但不再被使用。
- 勾了
user:read却没拿到user:write能力:原因是权限网格只下发单个具体权限点,勾一个不会带出另一个;后端匹配虽然认user:*前缀通配,但页面不提供这一中间档。处理:read/write分别勾选;若确实想按整个资源域授权(user:*),目前只能直连后端写role_permissions或调POST /roles手塞——不建议,覆盖面比逐点授权大且不透明。
- 列表找不到想象中的权限点(如
monitoring:read、config:write):该缺口已修复——PERM_GROUPS已补全至后端全量 22 资源域 52 权限点(含env/git-repo/sync-target/artifact/pipeline/monitoring/alert/security/config9 个资源域 27 点),网格现可选中全部权限点(详见 8 章缺陷记录)。处理:若仍找不到,确认该权限点是否为后端新增——新增权限点需同步更新前端常量。
- 「权限点」列显示「+N 个」,想看全:原因是列表只平铺前 6 个标签。处理:点行内「查看详情」,弹窗展示全部权限点标签与计数。
- 想创建一个"零权限"角色:直接只填角色名、不勾任何权限点即可创建;列表该行显示「未授权」,分配后账号没有任何额外能力。处理:确认有
role:write且角色名唯一;此类角色适合先建后补语义(但补不了——需要新建,见 6)。
- 给账号绑了新角色,需要重新登录才生效吗:不需要。登录态每次请求都回库现算「角色 → 权限点」并集,绑定后的下一请求即按新集合判定;从账号解绑同样立即失效,不必等 token 过期。处理:绑定成功(用户页有「已为用户分配角色…」提示)后直接重试目标接口;若仍 403,确认该角色确实包含所需权限点——多角色只会让并集变大、不会"稀释"已有能力,问题多半是点没勾对。
- 为何现在建不了名为 admin 的角色:后端
resolvePermissions把持有platform_admin或字面admin角色的账号直接短路为*:*(不检查该角色是否有实际权限点行),所以字面admin是按名提权的高危通道。已修复(提交 412e9c48):前端RESERVED_ROLE_NAMES(admin/platform_admin/project_admin/developer/operator/viewer6 项)在创建前拦截并提示「角色名 "xxx" 为系统保留名(admin/platform_admin 按名获 *:* 超级权限),禁止新建」(RolesPage.vue:166/230-235);后端CreateRole亦拒绝保留名(access_service.go:277-279),直连 API 也建不出。处理:换合法角色名;若历史上已误建admin并绑定,新建正确角色重绑并让误绑账号只保留新角色(误建角色无法删除,只能弃用,见 8 章)。
- 想授权某人"只能给账号分配既有角色、不能新建角色":现阶段做不到——后端把「分配/移除角色」(
POST /api/v1/users/{id}/roles、DELETE /api/v1/users/{id}/roles/{roleId})与「新建角色」(POST /api/v1/roles)挂在同一个权限点role:write上,没有更细的动作级拆分。处理:要么授role:write全量(含新建能力),要么保留由超管代劳分配;这是接口粒度边界(见 8 章)。
8. 已知缺陷与边界
| 缺陷/边界 | 说明 |
|---|---|
| PERM_GROUPS 硬编码且不全 | 已修复(提交 412e9c48):PERM_GROUPS 已补全至后端全量 22 资源域 52 权限点(RolesPage.vue:138-162,含阶段 B/C/D 的 环境/Git 仓库/同步目标/制品仓库/流水线/监控/告警自愈/安全合规/配置管理 9 组 27 点),与 action/products/apollo/access/permissions.go:12-86 的 PermXxx 常量清单逐项一致(*:* 另列超级权限组);经 UI 可完整复刻内置业务角色。仍为前端硬编码常量(后端新增权限点需同步改前端),但当前已无缺失资源域 |
| 无编辑/删除角色 | 后端仅注册 GET/POST /roles 与 GET /roles/:id;permissions.go 目录里的 PUT/DELETE /roles/:id 未注册 handler。错建/改名/调权限只能新建角色再重分配,废弃角色永久滞留列表 |
| 建角色非事务 | accessSvc.CreateRole 建角色行与逐条写 role_permissions 之间无事务:中途失败留半成品且角色名被占,同名重试撞 409;无前端可恢复路径(改库清理或换名)。属后端实现边界,非前端可修 |
| 权限点串无白名单校验 | 后端把请求 permissions[] 原样写 resource_name,不校验是否在 permissions.go 已知清单内(含手写请求传 *:* 生效);UI 勾选仅是入口约束 |
| 客户端过滤/分页 | 列表全量返回、前端按 10 条切片过滤;角色极多时首屏全量开销 |
| 详情/列表只显示 ACTION_ACCESS | permList 只提取 permission_type==='ACTION_ACCESS' 的权限点;COLUMN_ACCESS/FEATURE_ACCESS 等类型不在本页展示 |
| 角色表无时间列 | Role 表无 created_at/updated_at,JSON 用 gorm:"-" 忽略,列表/详情都不显示创建时间(与用户表不同) |
| 空权限创建合法 | 允许创建零权限角色(UI 不强制至少一项),须由使用者自行约束;这类角色分配后无实际能力 |
| 保留角色名无保护 | 已修复(提交 412e9c48):前端创建前拦截保留名(RESERVED_ROLE_NAMES 6 项,RolesPage.vue:166/230-235,提示「系统保留名(admin/platform_admin 按名获 : 超级权限),禁止新建」);后端 CreateRole 亦拒绝保留名(action/products/apollo/access/access_service.go:277-279,纵深防御防绕过前端直连 API)——admin/platform_admin 无感提权通道已封堵 |
| 新建与分配共用 role:write | POST /roles(新建)与 POST/DELETE /users/:id/roles[...](分配/移除)都用 role:write,无法授权"只能分配既有角色"的中间态,最小权限只能到 role:write 全量或退回超管代劳 |
网格无 resource:* 中间档但接口接受 | UI 只发单点或 *:*;手写 POST /roles 可写入 user:* 等前缀通配并被 PermissionMatch 真实放行。授权口径在 UI 与接口间不一致,审计里也看不出前缀通配的全覆盖面 |
| 列表失败与空态混淆 | 已修复(提交 412e9c48):列表区新增 loadError 独立错误态(v-else-if="loadError" 渲染红字「加载角色列表失败:{msg}」+「重试」按钮,模板顺序 loading > loadError > 空态 > 表格,RolesPage.vue:86-91;fetchRoles 成功清空、失败写入 :189-201)——失败不再渲染「暂无角色」,用户不会把权限问题误判成"没有角色" |
| 无角色名长度/字符校验 | 前端仅判非空,后端仅 trim+判重;超长/含特殊字符(如含 /、空格)的角色名可创建,仅在 URL/展示场景可能带来转义问题(当前无实际故障,属边界提示) |
附录 A:页面文案与关键常量速查
| 场景 | 源码取值/文案(逐字) |
|---|---|
| 页面标题 | 平台管理 · 角色管理 |
| 按钮 | 新建角色 / 查看详情 / 创建 / 提交中... / 取消 / 关闭 / 上一页 / 下一页 |
| 搜索 placeholder | 搜索角色名 |
| 角色计数 | 共 {{ filteredRoles.length }} 个角色 |
| 空态 | 加载中... / 暂无角色{{ keyword ? '(可调整关键字)' : '' }} |
| 新建字段 label | 角色名(name)* / 权限点(permissions,可多选) |
| 角色名 placeholder | 如 project_admin / developer |
| 权限超集组 | 22 组:期望状态/制品/发布通道/部署策略/签名者/部署编排/漂移/Agent/用户/角色/API Key/项目/审计/环境/Git 仓库/同步目标/制品仓库/流水线/监控/告警自愈/安全合规/配置管理 |
| 保留名拦截提示 | 角色名 "{name}" 为系统保留名(admin/platform_admin 按名获 *:* 超级权限),禁止新建 |
| 超级权限 | 超级权限 分组 + *:*(通配全部权限) |
| 权限点权限分组 title | {{ g.group }} |
| 详情弹窗标题 | 角色详情:{{ role_name }} |
| 详情 meta | ID:{{ id }} · 权限点 {{ permissions.length }} 个 |
| 详情空态 | 该角色未授予任何权限点。 |
| 未授权兜底 | 未授权 |
| 创建成功 | 角色 "{{ data.name }}" 创建成功 |
| 失败提示前缀 | 加载角色列表失败: / 创建角色失败: / 加载角色详情失败: |
| 前端判空提示 | 角色名不能为空 |
| 后端错误(可溯源) | 角色名不能为空(400) / 角色已存在(409) / 角色 {id} 不存在(404) / invalid role id(400) |
| 权限组常量 | PERM_GROUPS(RolesPage.vue 134–148 行) |
| 分页 | PAGE_SIZE=10,{{page}} / {{totalPages}} |