本文档介绍 WeKnora 空间内权限控制(Workspace RBAC) 的设计、角色矩阵、资源归属模型、配置方式,以及它与 共享空间 之间的关系。
状态:已随 #1303 发布,由配置项
tenant.enable_rbac控制,默认true(强制鉴权)。可临时切到false进入「仅记录不拦截」的灰度窗口。
在 RBAC 引入之前,只要通过 X-API-Key 或 JWT 认证成功,调用方在空间内基本等同于管理员。这在单人自部署场景没问题,但只要一个空间里出现两个及以上的真人成员(团队共享一套知识库),就必须区分:
- 谁可以删除知识库、撤销 API Key(管理员/Owner);
- 谁可以上传文档、编辑「自己」的知识库(Contributor);
- 谁只能读取与提问(Viewer)。
RBAC 在原有 JWT / API Key 认证之上,叠加了一层空间内角色矩阵,使三种状态都成为一等公民。
每个空间成员(tenant_members 一行)拥有且仅拥有一个角色:
| 角色 | 标识 | 典型场景 | 关键能力 |
|---|---|---|---|
| 只读 | viewer |
只查阅、提问的成员 | 仅读,不可发起任何变更 |
| 贡献者 | contributor |
上传文档、维护自己的 KB / Agent | 可变更 creator_id == 自己 的资源;他人资源等同 Viewer |
| 管理员 | admin |
空间内运维 | 可变更空间内任意资源;管理成员;配置共享基础设施(模型、解析器、存储、向量库等) |
| Owner | owner |
空间所有者 | Admin 的全部权限 + 可删除空间;每个空间至少有一位,可以有多位 |
角色按 viewer < contributor < admin < owner 递增,高角色继承低角色权限。
Owner 的数量约束是“至少一位”,而不是“只能一位”。系统允许同一空间存在多个活跃 Owner;降级或移除 Owner 时,只有会导致空间失去最后一位 Owner 的操作才会被拒绝。
- 跨空间超管:
User.CanAccessAllTenants=true且enable_cross_tenant_access=true时,通过X-Tenant-ID切换到目标空间后等同 Admin,不需要在目标空间里有tenant_members行。用于多空间运营方。 - API Key 调用:
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) + 共享关系表 |
管理员 / 编辑者 / 只读 |
两者正交:
- 共享空间不持有任何 KB 或 Agent,它只是「某 KB 以某权限被共享到某空间」的关系记录。
- 资源始终归属一个空间,归属与
creator_id都不会因为共享而改变。 - 一次对他人共享给你的 KB 的写操作,需要同时满足:
- 共享一侧:该 KB 被以「可写」权限共享到了你和发起方都在的共享空间;
- 空间角色一侧:你在该共享空间内不是「只读」(即至少是编辑者);
- 空间 RBAC 一侧:你的访问通过共享路径解析为对源空间的「以共享空间身份」访问,仍要经过源空间的 RBAC 检查。具体地,访问检查会在确认
kb.tenant_id == 你的当前空间不成立后,回落到共享路径校验。
简化的判定顺序(见 internal/middleware/kb_access.go):
┌────────────────────────┐
│ KB 属于我当前空间? │ ──是──► 进入空间 RBAC:
└──────┬─────────────────┘ 角色 + creator_id 决定能否写
否
▼
┌────────────────────────┐
│ KB 通过共享空间分享给 │ ──否──► 403 / 404
│ 我所在的某个空间? │
└──────┬─────────────────┘
是
▼
┌────────────────────────┐
│ 我在该空间是 viewer? │ ──是──► 只读
│ │ ──否──► 按共享时设定的「只读/可写」执行
└────────────────────────┘
要点整理:
- 共享空间不会绕过空间 RBAC:若一个 KB 在源空间里被标记为「仅 Admin+ 可写」(例如
creator_id为空的空间共有 KB),即使共享时给了「可写」权限,外空间成员也只能读取——因为没人能跨空间成为源空间的 Admin。 - API Key 跨空间访问:API Key 在所属空间内是 Admin,但不会因此自动获得对其他空间通过共享空间共享过来的 KB 的写权限——共享空间使用的是
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 ...)里仍然条条可见。
知识库活动记录也复用这张不可变审计表,通过 scope_type=knowledge_base、scope_id=<kb_id> 建立范围索引,而不是为每个知识库创建独立日志表。GET /api/v1/knowledge-bases/:id/activity 提供游标分页,覆盖知识库配置、知识/FAQ、标签、数据源、共享、克隆/移动任务和 Wiki 内容变更;原有空间审计接口只返回无资源范围的 RBAC 事件,避免知识库活动淹没成员与拒绝记录。解析阶段明细继续由 knowledge spans 展示,数据源同步明细继续由 sync logs 展示,活动记录只保存摘要与关联 ID。
该接口仅知识库创建者或所属空间 Admin+ 可读,并明确拒绝从组织共享空间读取,避免向接收方泄露源空间操作者与配置历史。活动详情不得写入知识正文、外部 URL、数据源凭据或原始错误堆栈。
后台 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确认:- 每个空间都只有一位 Owner;
- Contributor / Viewer 划分符合预期;
- 通过
PUT /api/v1/tenants/:id/members/:user_id/DELETE调整。每次调整都会写入audit_logs。
- 观察日志:抓取应用日志里的
[rbac] role insufficient (logged but not enforced) ...,这些就是切换到强制鉴权后会变成 403 的请求。逐条修正成员角色或客户端身份。 - 切回强制鉴权:删除
tenant.enable_rbac=false覆盖(或显式置true),重启服务。此后:- 角色不足 → 403;
- 同时写入
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 的按钮,前端直接隐藏而不是让用户点了再吃错误。
回填逻辑选「每个空间里最早活跃的用户」作为 Owner,其余人统一变成 Contributor。如果创建空间的账号其实是一个机器人 / 共用账号,可能需要先用 PUT /api/v1/tenants/:id/members/:user_id 把机器人降级、把真人 Admin 提升。
大概率脚本的 JWT 对应的成员是 Viewer / Contributor 而非 Admin。两种解法:
- 通过
tenant_members把对应用户升级到 Admin; - 或者把脚本切到
X-API-Key调用 —— API Key 在所属空间内固定 Admin。
请按第四节的判定顺序排查:
- KB 是否真的属于源空间、
tenant_id配置是否正确; - 该共享关系当前是否还存在(未被取消);
- 调用方在共享空间里是不是 Viewer;
- 若 KB 的
creator_id在源空间内为空且共享权限要求写,源空间的 RBAC 仍会要求 Admin+,导致跨空间写无法成立。
两种可能:
- 1 分钟滑动窗口去重,同一
(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 个用例。- Langfuse / OpenTelemetry span 上携带解析后的
TenantRole和TenantID,一次被拒绝的请求在 trace 中即可看到对应角色,不需要再去手工关联日志。
- 跨空间协作:
共享空间说明.md - 多空间认证背景:
OIDC认证调用流程.md - 配置项与环境变量:
.env.example



