docs/RBAC说明.md
本文档介绍 WeKnora 租户内权限控制(Tenant RBAC) 的设计、角色矩阵、资源归属模型、配置方式,以及它与 共享空间 之间的关系。
状态:已随 #1303 发布,由配置项
tenant.enable_rbac控制,默认true(强制鉴权)。可临时切到false进入「仅记录不拦截」的灰度窗口。
在 RBAC 引入之前,只要通过 X-API-Key 或 JWT 认证成功,调用方在租户内基本等同于管理员。这在单人自部署场景没问题,但只要一个租户里出现两个及以上的真人成员(团队共享一套知识库),就必须区分:
RBAC 在原有 JWT / API Key 认证之上,叠加了一层租户内角色矩阵,使三种状态都成为一等公民。
每个租户成员(tenant_members 一行)拥有且仅拥有一个角色:
| 角色 | 标识 | 典型场景 | 关键能力 |
|---|---|---|---|
| 只读 | viewer | 只查阅、提问的成员 | 仅读,不可发起任何变更 |
| 贡献者 | contributor | 上传文档、维护自己的 KB / Agent | 可变更 creator_id == 自己 的资源;他人资源等同 Viewer |
| 管理员 | admin | 租户内运维 | 可变更租户内任意资源;管理成员;配置共享基础设施(模型、解析器、存储、向量库等) |
| Owner | owner | 租户创建者 | Admin 的全部权限 + 可删除租户;不会被其他 Admin 降级;每个租户有且只有一位 |
角色按 viewer < contributor < admin < owner 递增,高角色继承低角色权限。
User.CanAccessAllTenants=true 且 enable_cross_tenant_access=true 时,通过 X-Tenant-ID 切换到目标租户后等同 Admin,不需要在目标租户里有 tenant_members 行。用于多租户运营方。X-API-Key 合成的虚拟用户在其所属租户内固定为 Admin(仅删除租户仍需 Owner)。脚本集成无需迁移。tenant_members 表里没有任何活跃成员(典型场景:仅 API Key 使用过该租户),首位通过认证的真人会被自动晋升为 Owner,避免锁死。光有角色矩阵不够,否则 Contributor 之间可以互相破坏。为此在迁移 000043 中给关键表加了 creator_id:
knowledge_bases.creator_id —— 老数据回填为该租户的 Owner;空串/NULL 表示「租户共有,仅 Admin+ 可变更」。custom_agents.creator_id —— Agent 创建者。custom_agents.runnable_by_viewer —— 默认 true,允许 Viewer 在对话中调用该 Agent;置 false 则提升到 Contributor 起步。子资源沿着归属链回溯到 KB 的 creator_id:
chunk_id ─► knowledge_id ─► kb_id ─► knowledge_bases.creator_id
FAQ 条目、生成的问题、KB 标签、Wiki 页面同理。
由此衍生出两类守卫:
Viewer() / Contributor() / Admin() / Owner() —— 只看角色。用于租户级基础设施(模型、向量库、IM 通道等)。OwnedKBOrAdmin() / OwnedAgentOrAdmin() / OwnedChunkKBOrAdmin() …… —— 「我是这条资源的 creator_id」或「我至少是 Admin」二者满足其一即可。用于具体资源的写操作。这样可以让「Contributor 在自己的 KB 里像 Owner,在别人的 KB 里像 Viewer」自然成立。
共享空间(Organization)和租户 RBAC 解决的是不同维度的问题,必须同时满足才能完成一次跨租户操作:
| 维度 | 解决什么 | 主键模型 | 角色集合 |
|---|---|---|---|
| 租户 RBAC | 同一租户内「你能对自己 / 别人 / 共享基础设施做什么」 | tenant_members(user_id, tenant_id, role) | viewer / contributor / admin / owner |
| 共享空间 | 跨租户「把我的 KB / Agent 让别的租户的人也用」 | organization_members(user_id, org_id, role) + 共享关系表 | 管理员 / 编辑者 / 只读 |
两者正交:
creator_id 都不会因为共享而改变。kb.tenant_id == 你的当前租户 不成立后,回落到共享路径校验。简化的判定顺序(见 internal/middleware/kb_access.go):
┌────────────────────────┐
│ KB 属于我当前租户? │ ──是──► 进入租户 RBAC:
└──────┬─────────────────┘ 角色 + creator_id 决定能否写
否
▼
┌────────────────────────┐
│ KB 通过共享空间分享给 │ ──否──► 403 / 404
│ 我所在的某个空间? │
└──────┬─────────────────┘
是
▼
┌────────────────────────┐
│ 我在该空间是 viewer? │ ──是──► 只读
│ │ ──否──► 按共享时设定的「只读/可写」执行
└────────────────────────┘
要点整理:
creator_id 为空的租户共有 KB),即使共享时给了「可写」权限,外租户成员也只能读取——因为没人能跨租户成为源租户的 Admin。organization_members.role,与 API Key 无关。audit_logs(rbac.member_* 动作),共享空间内的成员、共享关系变更由共享空间自身的接口记录。一句话总结:租户 RBAC 是「纵向」的纵深防御,共享空间是「横向」的协作通道;任何跨租户的有写副作用的操作,都要同时穿过这两道闸口。
config/config.yaml:
tenant:
# 默认 true,强制鉴权。改为 false 进入「仅记录不拦截」灰度窗口
enable_rbac: true
# 跨租户超管开关,默认 false
enable_cross_tenant_access: false
auth:
# self_serve(默认):任何人都可注册,自动建租户 + Owner 成员
# invite_only :禁止公开注册,新用户必须通过 /tenants/:id/members 邀请进入
registration_mode: self_serve
audit:
# 审计日志保留天数;每日后台清理;默认 90;置 0 关闭清理
retention_days: 90
环境变量(优先级高于 YAML):
| 环境变量 | YAML 路径 | 取值 |
|---|---|---|
WEKNORA_TENANT_ENABLE_RBAC | tenant.enable_rbac | true / false |
WEKNORA_AUDIT_RETENTION_DAYS | audit.retention_days | 非负整数 |
auth.registration_mode 没有专属环境变量,沿用历史的 DISABLE_REGISTRATION=true——一旦设置,启动时会把 auth.registration_mode 强制改成 invite_only,保证后端 API 和 /auth/config 驱动的前端注册入口一致。
启动日志会打印一行总结,确认本次启动到底使用了哪一组配置以及覆盖来源。
audit_logs 表统一记录权限相关事件:
| Action | Outcome | 触发时机 |
|---|---|---|
rbac.member_added | success | POST /tenants/:id/members 成功 |
rbac.member_removed | success | DELETE /tenants/:id/members/:user_id 成功 |
rbac.member_role_changed | success | PUT /tenants/:id/members/:user_id 成功 |
rbac.member_left | success | POST /tenants/:id/members/leave 成功 |
rbac.access_denied | denied | RequireRole / RequireOwnershipOrRole 拒绝时(仅 enforcement 开启时) |
access_denied 采用 1 分钟滑动窗口去重,防止恶意探测刷表;同样的拒绝在应用日志([rbac] role insufficient ...)里仍然条条可见。
后台 goroutine AuditLogRetentionRunner 启动 ~10 分钟后开始首轮清理,之后每 24 小时清扫一次超过 audit.retention_days 的旧行;保留期为 0 时整条 goroutine 短路,不产生任何 DB 流量。
无论是自部署运维还是上游仓库本身,从「仅记录」切到「强制鉴权」都建议走以下流程:
tenant.enable_rbac=false(或环境变量)。否则默认就是强制鉴权——schema 落地、tenant_members 自动回填(每租户一个 Owner,其余 Contributor)、所有 KB 自动写入 creator_id。GET /api/v1/tenants/:id/members 确认:
PUT /api/v1/tenants/:id/members/:user_id / DELETE 调整。每次调整都会写入 audit_logs。[rbac] role insufficient (logged but not enforced) ...,这些就是切换到强制鉴权后会变成 403 的请求。逐条修正成员角色或客户端身份。tenant.enable_rbac=false 覆盖(或显式置 true),重启服务。此后:
audit_logs.rbac.access_denied(受去重控制)。auth.registration_mode 改为 invite_only。登录页注册入口会自动消失,POST /auth/register 直接 403。export WEKNORA_TENANT_ENABLE_RBAC=false
# 重启服务即可回到观察模式
tenant_members 行与 creator_id 列保留,下次再启用无需重做回填。除非彻底放弃这个功能,否则不要回滚 000043 / 000044 迁移——down.sql 会丢弃 tenant_members 和 audit_logs 整张表。
Pinia 中的 authStore 暴露:
authStore.currentTenantRole:成员信息加载完成前为 ''(loading 信号,按钮等待解析后再渲染,避免「先亮再灰」的闪烁);之后为四种角色之一。authStore.hasRole('admin') 等:按层级判断的便捷函数。isOwner(如 kb.creator_id === authStore.user?.id)做 per-resource 判断。这是后端守卫的镜像:任何在后端会 403 的按钮,前端直接隐藏而不是让用户点了再吃错误。
<sub>同时展示「待接受的邀请」和「空间成员」两组列表;只有 Owner 可以新增 / 移除成员;右上角的「审计日志」入口跳转到 <code>audit_logs</code> 视图。</sub> </td>
</tr> <tr> <td width="50%" align="center"> <b>用户菜单 + 工作区切换器</b><sub>左侧:当前空间角色徽章 / 设置入口 / 退出;右侧:切换到其它空间,「当前」角标标识活跃工作区。</sub> </td> <td width="50%" align="center"> <b>自助创建工作区</b>
<sub>任何用户都可以自助创建租户,创建后自动成为新空间的 Owner(受 <code>WEKNORA_TENANT_MAX_PER_USER</code> 上限保护)。</sub> </td>
</tr> <tr> <td colspan="2" align="center"> <b>待处理邀请弹窗</b><sub>用户菜单上的邀请铃铛会展示来自其它空间的待处理邀请,可直接「接受 / 拒绝」;7 天未响应自动过期。</sub> </td>
</tr> </table>回填逻辑选「每个租户里最早活跃的用户」作为 Owner,其余人统一变成 Contributor。如果创建租户的账号其实是一个机器人 / 共用账号,可能需要先用 PUT /api/v1/tenants/:id/members/:user_id 把机器人降级、把真人 Admin 提升。
大概率脚本的 JWT 对应的成员是 Viewer / Contributor 而非 Admin。两种解法:
tenant_members 把对应用户升级到 Admin;X-API-Key 调用 —— API Key 在所属租户内固定 Admin。请按第四节的判定顺序排查:
tenant_id 配置是否正确;creator_id 在源租户内为空且共享权限要求写,源租户的 RBAC 仍会要求 Admin+,导致跨租户写无法成立。两种可能:
(actor, path, action) 一分钟内只会写一行。完整序列在应用日志里。tenant.enable_rbac=false 时仅记录成员管理事件,不写 rbac.access_denied。v1 不支持。这个矩阵刻意保持成一个小固定格(Viewer < Contributor < Admin < Owner)+ 每种资源一个「creator escape hatch」。再细的策略(例如「Viewer 可以看自己的审计日志」)属于后续。
make test 覆盖 internal/middleware/rbac_test.go、internal/handler/rbac_lookups_test.go、internal/application/service/audit_log_test.go、internal/middleware/rbac_audit_test.go 等约 25 个用例。TenantRole 和 TenantID,一次被拒绝的请求在 trace 中即可看到对应角色,不需要再去手工关联日志。共享空间说明.mdOIDC认证调用流程.md.env.example