docs: add Chinese RBAC guide and link with shared space docs

Replace the English docs/rbac.md with a comprehensive Chinese
docs/RBAC说明.md and a wiki-style summary under docs/wiki/安全认证/.
Explain how tenant RBAC relates to the shared space feature (they
are orthogonal: tenant RBAC is the vertical defense, shared space
is the horizontal collaboration channel) and cross-link the two
docs in both the flat and wiki trees. Update inbound references in
.env.example, docker-compose.yml, and the auth legacy env test to
point at the new file name.
This commit is contained in:
wizardchen
2026-05-21 12:27:10 +08:00
committed by lyingbug
parent 7aca1017db
commit 7ebb29cd3e
9 changed files with 349 additions and 380 deletions
+1 -1
View File
@@ -435,7 +435,7 @@ WEKNORA_SANDBOX_TIMEOUT=60
# 启用租户级 RBAC(基于 tenant_members 表的角色强制鉴权)。
# - true(默认):开启后,无 active membership 或角色不足的请求会被 403 拒绝
# - false:观察模式,记录但不拦截,仅用于上线初期审计角色分配的灰度窗口
# 详细灰度方案见 docs/rbac.md。
# 详细灰度方案见 docs/RBAC说明.md。
# 注意:开发环境用 Air 热重载时,修改本变量需重启 dev 脚本(仅源代码变更才重读 .env)。
# WEKNORA_TENANT_ENABLE_RBAC=true
+1 -1
View File
@@ -171,7 +171,7 @@ services:
# Agent LLM call timeout
- WEKNORA_AGENT_LLM_TIMEOUT=${WEKNORA_AGENT_LLM_TIMEOUT:-}
- WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT=${WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT:-}
# Tenant / RBAC(详见 docs/rbac.md 与 .env.example 注释)
# Tenant / RBAC(详见 docs/RBAC说明.md 与 .env.example 注释)
# - WEKNORA_TENANT_ENABLE_RBAC: 是否启用租户角色强制鉴权(true / false),默认 true
# - WEKNORA_TENANT_MAX_OWNED_PER_USER: 单个非超管自助创建租户的上限
# >0 强制限额;=0 走 handler 默认;<0 关闭限额(不建议共享部署使用)
+226
View File
@@ -0,0 +1,226 @@
# 租户 RBAC 说明
本文档介绍 WeKnora **租户内权限控制(Tenant RBAC** 的设计、角色矩阵、资源归属模型、配置方式,以及它与 [共享空间](./共享空间说明.md) 之间的关系。
> 状态:已随 #1303 发布,由配置项 `tenant.enable_rbac` 控制,默认 `true`(强制鉴权)。可临时切到 `false` 进入「仅记录不拦截」的灰度窗口。
## 一、为什么需要 RBAC
在 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 的全部权限 + 可删除租户;不会被其他 Admin 降级;每个租户**有且只有一位** |
角色按 `viewer < contributor < admin < 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」自然成立。
## 四、与共享空间的关系(重点)
[共享空间](./共享空间说明.md)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 的写操作,需要同时满足:
1. **共享一侧**:该 KB 被以「可写」权限共享到了你和发起方都在的共享空间;
2. **空间角色一侧**:你在该共享空间内不是「只读」(即至少是编辑者);
3. **租户 RBAC 一侧**:你的访问通过共享路径解析为对源租户的「以共享空间身份」访问,仍要经过源租户的 RBAC 检查。具体地,访问检查会在确认 `kb.tenant_id == 你的当前租户` 不成立后,回落到共享路径校验。
简化的判定顺序(见 `internal/middleware/kb_access.go`):
```text
┌────────────────────────┐
│ 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`
```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 流量。
## 七、灰度上线建议
无论是自部署运维还是上游仓库本身,从「仅记录」切到「强制鉴权」都建议走以下流程:
1. **升级**:若想保留观察窗口,升级前先设置 `tenant.enable_rbac=false`(或环境变量)。否则默认就是强制鉴权——schema 落地、`tenant_members` 自动回填(每租户一个 Owner,其余 Contributor)、所有 KB 自动写入 `creator_id`
2. **核对成员**:调用 `GET /api/v1/tenants/:id/members` 确认:
- 每个租户都只有一位 Owner
- Contributor / Viewer 划分符合预期;
- 通过 `PUT /api/v1/tenants/:id/members/:user_id` / `DELETE` 调整。每次调整都会写入 `audit_logs`
3. **观察日志**:抓取应用日志里的 `[rbac] role insufficient (logged but not enforced) ...`,这些就是切换到强制鉴权后会变成 403 的请求。逐条修正成员角色或客户端身份。
4. **切回强制鉴权**:删除 `tenant.enable_rbac=false` 覆盖(或显式置 `true`),重启服务。此后:
- 角色不足 → 403
- 同时写入 `audit_logs.rbac.access_denied`(受去重控制)。
5. **可选:禁用公开注册**:把 `auth.registration_mode` 改为 `invite_only`。登录页注册入口会自动消失,`POST /auth/register` 直接 403。
### 回滚
```bash
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 的按钮,前端直接隐藏而不是让用户点了再吃错误。**
## 九、常见问题
### 升级后所有人都变成了 Contributor,找不到 Admin
回填逻辑选「每个租户里最早活跃的用户」作为 Owner,其余人统一变成 Contributor。如果创建租户的账号其实是一个机器人 / 共用账号,可能需要先用 `PUT /api/v1/tenants/:id/members/:user_id` 把机器人降级、把真人 Admin 提升。
### 切到强制鉴权后某个脚本开始 403?
大概率脚本的 JWT 对应的成员是 Viewer / Contributor 而非 Admin。两种解法:
- 通过 `tenant_members` 把对应用户升级到 Admin
- 或者把脚本切到 `X-API-Key` 调用 —— API Key 在所属租户内固定 Admin。
### 共享空间里的成员为什么读不到我「以可写共享」过去的 KB?
请按第四节的判定顺序排查:
- KB 是否真的属于源租户、`tenant_id` 配置是否正确;
- 该共享关系当前是否还存在(未被取消);
- 调用方在共享空间里是不是 Viewer;
- 若 KB 的 `creator_id` 在源租户内为空且共享权限要求写,源租户的 RBAC 仍会要求 Admin+,导致跨租户写无法成立。
### 审计日志怎么有些 403 没记录?
两种可能:
- 1 分钟滑动窗口去重,同一 `(actor, path, action)` 一分钟内只会写一行。完整序列在应用日志里。
- `tenant.enable_rbac=false` 时仅记录成员管理事件,不写 `rbac.access_denied`
### 我能不能做比「角色 + 归属」更细的 ACL?
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`](./共享空间说明.md)
- 多租户认证背景:[`OIDC认证调用流程.md`](./OIDC认证调用流程.md)
- 配置项与环境变量:[`.env.example`](../.env.example)
-377
View File
@@ -1,377 +0,0 @@
# Tenant RBAC Guide
How WeKnora enforces who-can-do-what inside a tenant, how to roll the
feature out without breaking existing deployments, and how to audit
the system once it is on.
> Status: shipped behind a feature flag (`tenant.enable_rbac`,
> default `true`). Schema and `tenant_members` rows are populated
> on every install; operators may opt into a logging-only rollout
> window by setting the flag to `false`.
## Why this exists
Before #1303 every authenticated user with `X-API-Key` or a JWT was
effectively an Admin in their tenant. That's fine for a single-user
self-host, but as soon as a tenant has more than one human (e.g. a
small team sharing a knowledge base) you need to draw lines around:
- who may delete a knowledge base or revoke an API key (Admins);
- who may upload documents and edit their own KB (Contributors);
- who may only read and ask questions (Viewers).
RBAC layers a **per-tenant role matrix** on top of the existing JWT /
API-key auth so all three states are first-class.
## The role matrix
Every tenant member has exactly one role, stored in `tenant_members.role`:
| Role | Typical use | Notable powers |
|---------------|----------------------------------------------|----------------|
| `viewer` | Read-only consumer (chat, search, browse). | None — reads only. |
| `contributor` | Upload + edit **own** KBs / agents. | Can mutate resources whose `creator_id` matches their user ID. Cannot touch other contributors' resources. |
| `admin` | Tenant-wide operator. | Can mutate any resource in the tenant; manages members; configures shared infrastructure (Ollama, parser, storage, etc.). |
| `owner` | Tenant founder. | Same as Admin, plus may delete the tenant and cannot be demoted by another Admin. Exactly one Owner per tenant after backfill. |
Higher roles inherit lower roles' permissions. The hierarchy is
`viewer < contributor < admin < owner`.
### Special cases the auth layer still handles
- **Cross-tenant superusers** (`User.CanAccessAllTenants` + the
`enable_cross_tenant_access` flag + `X-Tenant-ID` header) get an
Admin-level pass into the target tenant without needing a row in
`tenant_members`. Used by org-level operators who administer many
tenants.
- **API-key callers** (`X-API-Key` synthetic users) are pinned to
Admin in the tenant the key belongs to. This preserves every
scripted-integration use case except tenant deletion (which still
requires Owner).
- **Orphan tenants** (zero `tenant_members` rows — typically
API-key-only tenants) auto-promote the first authenticating human
to Owner. Prevents lock-out after a fresh install.
## How enforcement is gated
Two routes a request can take:
```text
┌─────────────────────┐
JWT / API-key ──► auth ──► │ tenant_members │
│ lookup → role │
└─────────┬───────────┘
┌────────┴────────┐
│ EnableRBAC? │
└────────┬────────┘
┌────────────────────────┴────────────────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ false │ │ true (default) │
│ role logged but │ │ role enforced; │
│ not enforced; │ │ denials emit a 403 │
│ ownership lookups │ │ AND a durable row │
│ skipped entirely │ │ in audit_logs │
└──────────────────────┘ └──────────────────────┘
```
When `tenant.enable_rbac=false` (the opt-in rollout window):
- `RequireRole` middleware logs the would-be reject and lets the
request through (`[rbac] role insufficient (logged but not
enforced) ...`). Use these logs to audit your role assignments
*before* flipping the flag.
- `RequireOwnershipOrRole` short-circuits before even running its
creator-lookup closure, so the dormant rollout window incurs zero
extra DB roundtrips on hot mutation paths.
- `audit_logs` only records member-management events
(`rbac.member_added` etc.). Access-denied rows start landing only
when enforcement is on.
When `tenant.enable_rbac=true`:
- Insufficient role → HTTP 403 + a durable
`rbac.access_denied` row in `audit_logs` (subject to a 1-minute
sliding-window dedup so probing clients can't fill the table).
- Ownership lookup runs and decides whether the caller is the
resource creator. Genuine "row missing" (404) is surfaced as 404,
not 403, so client diagnostics still work.
## Configuration
YAML (`config/config.yaml`):
```yaml
tenant:
# Default true. Set to false for a logging-only rollout window while
# role assignments are being audited; flip back once verified.
enable_rbac: true
# Optional: leaves the existing cross-tenant superuser flag in place.
enable_cross_tenant_access: false
auth:
# self_serve (default) — anyone may register; new tenant + Owner
# membership auto-created.
# invite_only — public registration is rejected; new users
# enter only via /tenants/:id/members invitations.
registration_mode: self_serve
audit:
# Days of audit history retained. A daily background sweep deletes
# rows older than this. Default 90 (set automatically when the
# `audit:` section is omitted from the YAML); set to 0 to disable
# the purge entirely (the table grows monotonically).
retention_days: 90
```
Environment overrides (always win over YAML):
| Env var | YAML key | Values |
|--------------------------------------|--------------------------------|------------------------------|
| `WEKNORA_TENANT_ENABLE_RBAC` | `tenant.enable_rbac` | `true` / `false` |
| `WEKNORA_AUDIT_RETENTION_DAYS` | `audit.retention_days` | non-negative integer |
`auth.registration_mode` has no dedicated env override to avoid
duplicating the long-standing `DISABLE_REGISTRATION` env knob. When
`DISABLE_REGISTRATION=true` is set, startup coerces
`auth.registration_mode` to `invite_only` so both the API gate and the
`/auth/config`-driven UI gate (frontend hides the registration entry)
stay consistent. To pick a mode without the env var, set
`auth.registration_mode` in `config.yaml`.
The startup logger emits one line summarising both effective values
plus their override sources, so you can confirm at boot which mode
the deployment is in.
## Schema reference
### `tenant_members`
```sql
CREATE TABLE tenant_members (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
tenant_id BIGINT NOT NULL,
role VARCHAR(32) NOT NULL, -- owner | admin | contributor | viewer
status VARCHAR(32) NOT NULL DEFAULT 'active',
joined_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_tenant_members_user_tenant_unique
ON tenant_members(user_id, tenant_id) WHERE status = 'active';
```
A user belongs to a tenant by having an `active` row in this table.
The `(user_id, tenant_id)` uniqueness is conditional on `status =
'active'` so historical revoked rows do not block a re-invite.
### Per-resource ownership
Migration 000043 added two columns the role check leans on:
- `knowledge_bases.creator_id VARCHAR(36)` — backfilled to the tenant
Owner for legacy rows. Empty string / NULL means "tenant-owned, no
human creator" and only role ≥ min may mutate.
- `custom_agents.runnable_by_viewer BOOLEAN` — when true (default),
Viewers can run the agent in chat without a role bump.
Sub-resources resolve up the ownership chain:
```
chunk_id ─► knowledge_id ─► kb_id ─► knowledge_bases.creator_id
```
Same for FAQ entries, generated questions, tags, and wiki pages.
### `audit_logs`
```sql
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
actor_user_id VARCHAR(36) NOT NULL DEFAULT '',
actor_role VARCHAR(32) NOT NULL DEFAULT '',
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL DEFAULT '',
target_id VARCHAR(64) NOT NULL DEFAULT '',
target_user_id VARCHAR(36) NOT NULL DEFAULT '',
request_path VARCHAR(512) NOT NULL DEFAULT '',
request_method VARCHAR(16) NOT NULL DEFAULT '',
outcome VARCHAR(16) NOT NULL DEFAULT 'success',
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
```
Built-in actions today:
| Action | Outcome | When |
|------------------------------|----------------------|------|
| `rbac.member_added` | success | `POST /tenants/:id/members` succeeds |
| `rbac.member_removed` | success | `DELETE /tenants/:id/members/:user_id` succeeds |
| `rbac.member_role_changed` | success | `PUT /tenants/:id/members/:user_id` succeeds |
| `rbac.member_left` | success | `POST /tenants/:id/members/leave` succeeds |
| `rbac.access_denied` | denied | `RequireRole` / `RequireOwnershipOrRole` rejects (only when enforcement is on) |
The schema is intentionally generic so future PRs can add KB / agent /
chunk action constants without another migration.
A daily background goroutine (`AuditLogRetentionRunner`, `service/`)
sweeps rows older than `audit.retention_days`. The first sweep fires
~10 minutes after boot to stay out of the way of startup traffic; the
loop then runs every 24 h. The runner short-circuits when retention
is `0`, so disabling it costs zero DB round-trips.
## Route guards
Centralised in `internal/router/rbac.go` as `rbacGuards`:
| Guard | What it requires |
|------------------------------------------|------------------|
| `g.Viewer()` | Tenant member, any role |
| `g.Contributor()` | role ≥ Contributor |
| `g.Admin()` | role ≥ Admin |
| `g.Owner()` | role = Owner |
| `g.OwnedKBOrAdmin()` | KB.creator_id == caller, OR Admin+ |
| `g.OwnedKBOrAdminFromKbIDParam()` | Same, but reads `:kb_id` from a non-`:id` param |
| `g.OwnedAgentOrAdmin()` | CustomAgent.creator_id == caller, OR Admin+ |
| `g.OwnedKnowledgeKBOrAdmin()` | Resolves `:knowledge_id` → KB.creator_id |
| `g.OwnedChunkKBOrAdmin()` | Resolves `:chunk_id` → knowledge → KB.creator_id |
| `g.OwnedChunkKBOrAdminFromChunkID()` | Same chain, but starts from a different param name |
| `g.OwnedWikiKBOrAdmin()` | Resolves wiki page → KB.creator_id |
| `g.PathTenantMatch()` | URL `:tenant_id` matches the auth context |
| `g.CrossTenant()` | Caller has cross-tenant access |
Read-only endpoints stay on `g.Viewer()`. Anything that mutates a
shared infrastructure resource (Ollama install, parser/storage check,
WeKnora Cloud credentials, web-search providers, vector stores,
chat-history config) goes on `g.Admin()`. Anything per-resource picks
the matching `Owned*OrAdmin` guard.
## Rollout playbook
The same playbook a self-host operator (or the Tencent/WeKnora repo
itself) should follow when promoting a deployment from "logged" to
"enforced":
1. **Upgrade.** Set `tenant.enable_rbac=false` (or
`WEKNORA_TENANT_ENABLE_RBAC=false`) before restarting on the new
release if you want a logging-only window — the default is now
`true`, so skipping this step jumps straight to enforcement.
Either way: schema lands, `tenant_members` is backfilled (one
Owner per tenant, the rest Contributor), `creator_id` populated
on every KB.
2. **Audit the membership.** Call
`GET /api/v1/tenants/:id/members` (Admin+) and confirm:
- Exactly one Owner per tenant.
- The right humans are Contributors, Viewers, etc. (matters most
in tenants where many users share an API key today and got
auto-promoted to Contributor by the backfill).
- Adjust with `PUT /api/v1/tenants/:id/members/:user_id` /
`DELETE` as needed. Every change writes an `audit_logs` row.
3. **Watch the dormant logs.** Tail the API logs for `[rbac] role
insufficient (logged but not enforced)` lines. These tell you
exactly which production calls *would* 403 if you flipped the
flag now. Fix any user role that produces unexpected denials.
4. **Flip the flag back on.** Remove the `tenant.enable_rbac=false`
override (or set it back to `true`) and restart the app. From
this point on:
- Insufficient role → 403.
- `audit_logs` records every reject (subject to dedup).
5. **Optional — enable invite-only.** If you've moved off self-serve
registration, set `auth.registration_mode=invite_only`. The
Register tab disappears from `/login` and the server rejects
`POST /auth/register` with 403.
### Rollback
If enforcement causes unexpected breakage:
```bash
# Flip the flag back via env (no restart-with-rebuild needed):
export WEKNORA_TENANT_ENABLE_RBAC=false
# Restart the app.
```
Membership rows and `creator_id` columns stay populated so re-enabling
later doesn't require another backfill. Migration 000043 / 000044
both ship `down.sql`s, but rolling those back drops `tenant_members`
and `audit_logs` entirely — only do that if you intend to remove the
feature for good.
## Frontend behaviour
The Pinia auth store exposes:
- `authStore.currentTenantRole` — `''` until membership resolves,
then one of the four roles. Use the empty string as a "loading"
signal; flashing a privileged button before the role is known is
worse UX than waiting.
- `authStore.hasRole('admin')` etc. — convenience helpers that walk
the hierarchy.
Every mutation surface in the UI is gated either by a role check or
by a per-resource ownership predicate (`isOwner` computed off
`kb.creator_id === authStore.user?.id`). This is the matching pair
to the backend guard — when a button would 403 on click, we hide the
button instead of letting the user discover it through a failed
request.
## Common questions
### "I upgraded and now everyone is a Contributor instead of an Admin."
The backfill picks **the earliest active user in each tenant** as
that tenant's Owner; everyone else becomes Contributor. If a single
human-shared account or API key created the tenant, you may need to
demote bots and re-promote your real Admin via
`PUT /api/v1/tenants/:id/members/:user_id`.
### "I flipped the flag and a script started getting 403."
Likely the script authenticates as a Viewer / Contributor instead of
the Admin you assumed. Check the JWT user, look up the matching
`tenant_members.role`, promote if appropriate. Or — if the script
should be tenant-wide — switch it to authenticate via `X-API-Key`,
which is pinned to Admin.
### "Why is the audit log not catching some 403s?"
Two reasons:
- Sliding-window dedup. The same `(actor, path, action)` tuple
writes at most one durable row per minute. The full reject series
is still in the perishable application log
(`[rbac] role insufficient ...`).
- Enforcement off. `rbac.access_denied` only writes when
`tenant.enable_rbac=true`. The dormant mode just emits the warning
log; member-management events do still write durably.
### "Can I have per-route ACL more granular than role + creator?"
Not in v1. The matrix is intentionally a small fixed lattice
(Viewer < Contributor < Admin < Owner) with one ownership escape
hatch per resource. Anything finer (e.g. "viewer can see audit log
for their own actions") is a follow-up.
### "Where do I see the audit log in the UI?"
`Settings → Members → Audit Log` tab (Admin+ only). Cursor-paginated
chronological feed with action / outcome chips. Filter UI is a v2.
## Testing & observability
- `make test` covers `internal/middleware/rbac_test.go`,
`internal/handler/rbac_lookups_test.go`,
`internal/application/service/audit_log_test.go` and
`internal/middleware/rbac_audit_test.go` (~25 cases).
- e2e smoke under both flag values is documented in #1303 PR
series. The matrix that ships green: each role × each guarded
route → expected status code.
- Langfuse / OTel spans carry the resolved `TenantRole` and
`TenantID`, so a denied request shows up as a single trace with
the role on it — no need to correlate logs and traces by hand.
+4
View File
@@ -42,6 +42,7 @@ aliases: [Home, Index, wiki首页]
| 页面 | 简介 |
|------|------|
| [OIDC认证调用流程](安全认证/OIDC认证调用流程.md) | OIDC 第三方登录的完整调用链路 |
| [租户RBAC说明](安全认证/RBAC说明.md) | 租户内角色矩阵、资源归属与审计 |
| [共享空间说明](安全认证/共享空间说明.md) | 跨租户协作与知识库/智能体共享 |
## 开发与部署
@@ -94,6 +95,7 @@ graph TB
集成扩展 --> VecDB[集成向量数据库]
安全认证 --> OIDC[OIDC认证调用流程]
安全认证 --> RBAC[租户RBAC说明]
安全认证 --> SharedSpace[共享空间说明]
开发部署 --> DevGuide[开发指南]
@@ -109,6 +111,8 @@ graph TB
Skills -.-> IM
IM -.-> DS
DS -.-> SharedSpace
OIDC -.-> RBAC
RBAC -.-> SharedSpace
OIDC -.-> SharedSpace
LITE -.-> SharedSpace
WebSearch -.-> VecDB
+112
View File
@@ -0,0 +1,112 @@
---
title: 租户RBAC说明
tags: [安全认证, RBAC, 权限, 多租户, 角色]
aliases: [RBAC, 角色权限, 租户角色, TenantRBAC]
source: RBAC说明.md
---
# 租户 RBAC 说明
本文档介绍 WeKnora 的**租户内权限控制(Tenant RBAC)**,包括角色矩阵、资源归属模型,以及它与 [共享空间](./共享空间说明.md) 的关系。
> 状态:已发布;由配置项 `tenant.enable_rbac` 控制,默认 `true`(强制鉴权)。
> 完整说明、灰度方案、Schema、路由守卫等参见 [`docs/RBAC说明.md`](../../RBAC说明.md)。
## 解决的问题
RBAC 引入前,只要通过 `X-API-Key` 或 JWT 认证成功,调用方在租户内基本等同管理员。一旦一个租户出现多名真人成员,就需要区分:
- 谁可以删除知识库、撤销 API Key;
- 谁可以编辑「自己」的 KB / Agent;
- 谁只读。
## 角色矩阵
| 角色 | 标识 | 关键能力 |
|------|------|----------|
| 只读 | `viewer` | 仅读 |
| 贡献者 | `contributor` | 可变更 `creator_id == 自己` 的资源;他人资源按 Viewer |
| 管理员 | `admin` | 可变更租户内任意资源;管理成员、共享基础设施 |
| Owner | `owner` | Admin + 可删租户;每个租户唯一 |
层级 `viewer < contributor < admin < owner`,高角色继承低角色。
### 鉴权层的例外
- **跨租户超管**`enable_cross_tenant_access` 打开且账号 `CanAccessAllTenants=true`,通过 `X-Tenant-ID` 切换后等同 Admin。
- **API Key**:合成虚拟用户在所属租户内固定 Admin(删租户除外)。
- **孤儿租户自愈**:首位认证的真人自动晋升 Owner,避免 API Key-only 租户锁死。
## 资源归属
迁移 `000043` 在关键表加上 `creator_id`
- `knowledge_bases.creator_id` —— 老数据回填为该租户的 Owner;
- `custom_agents.creator_id` + `runnable_by_viewer`(默认 `true`,允许 Viewer 在对话中调用)。
子资源沿 `chunk → knowledge → kb → creator_id` 链回溯。
由此得到两类守卫:
- **角色守卫**:只看角色,用于租户级基础设施(模型、向量库、IM 通道等)。
- **归属守卫**`OwnedXxxOrAdmin`creator 或 Admin+ 二者其一即放行,用于具体资源写操作。
## 与共享空间的关系
| 维度 | 解决什么 | 主键 |
|------|---------|------|
| **租户 RBAC** | 同一租户内「你能对自己/别人/共享基础设施做什么」 | `tenant_members(user_id, tenant_id, role)` |
| **共享空间** | 跨租户「让别的租户的人也能用我的 KB / Agent」 | `organization_members` + 共享关系 |
两者**正交**
- 共享空间不持有 KB / Agent,只记录「以何种权限共享到了哪个空间」;资源归属与 `creator_id` 不变;
- 一次对**他人共享过来**的 KB 的写操作,需要同时满足:共享时设了「可写」 + 你在该空间不是 Viewer + 源租户的 RBAC 仍然放行;
- API Key 跨空间访问**不会**带 Admin 光环——共享路径由 `organization_members.role` 决定,与 API Key 无关。
判定顺序见 `internal/middleware/kb_access.go`
```text
KB 属于我当前租户?─是─► 走租户 RBAC(角色 + creator_id
└─否─► KB 共享给我所在空间?─是─► 取 min(共享权限, 空间角色)
└─否─► 403 / 404
```
一句话:**租户 RBAC 是纵向的纵深防御,共享空间是横向的协作通道;跨租户写动作必须同时穿过两道闸口。**
## 配置
```yaml
tenant:
enable_rbac: true # false 则进入「仅记录不拦截」灰度窗口
enable_cross_tenant_access: false
auth:
registration_mode: self_serve # 或 invite_only
audit:
retention_days: 90 # 0 表示不清理
```
环境变量 `WEKNORA_TENANT_ENABLE_RBAC` / `WEKNORA_AUDIT_RETENTION_DAYS` 覆盖 YAML。`DISABLE_REGISTRATION=true` 等价于把 `registration_mode` 强制设为 `invite_only`
## 审计
`audit_logs` 表记录:
- `rbac.member_added` / `removed` / `role_changed` / `left`
- `rbac.access_denied`(仅强制鉴权时;1 分钟滑动窗口去重)
每日后台 goroutine 清理超过 `audit.retention_days` 的旧行。
## 相关主题
- [共享空间说明](./共享空间说明.md) — 跨租户协作与共享,与 RBAC 正交
- [OIDC认证调用流程](./OIDC认证调用流程.md) — 多租户用户体系的认证入口
- [Lite与标准版区别](../项目概述/Lite与标准版区别.md) — Lite 单用户场景下 RBAC 实际不发挥作用
---
## 反向链接
- [Home](../Home.md) — Wiki 首页导航
- [共享空间说明](./共享空间说明.md) — 共享空间访问最终落到租户 RBAC 校验
- [OIDC认证调用流程](./OIDC认证调用流程.md) — JWT 解析后即进入 RBAC 角色匹配
@@ -60,6 +60,7 @@ source: 共享空间说明.md
## 相关主题
- [租户RBAC说明](./RBAC说明.md) — 单租户内的角色与资源归属,跨租户写操作需同时满足两边
- [Lite与标准版区别](../项目概述/Lite与标准版区别.md) — Lite 不支持共享空间
- [OIDC认证调用流程](../安全认证/OIDC认证调用流程.md) — 多租户场景下的用户认证
- [数据源导入开发](../集成扩展/数据源导入开发.md) — 数据源导入的知识库可被共享
@@ -70,6 +71,7 @@ source: 共享空间说明.md
## 反向链接
- [Home](../Home.md) — Wiki 首页导航
- [租户RBAC说明](./RBAC说明.md) — 共享空间访问最终落到租户 RBAC 校验
- [Lite与标准版区别](../项目概述/Lite与标准版区别.md) — Lite 不支持共享空间
- [OIDC认证调用流程](../安全认证/OIDC认证调用流程.md) — 多租户用户体系支撑共享空间
- [数据源导入开发](../集成扩展/数据源导入开发.md) — 导入的知识库可通过共享空间共享
+2
View File
@@ -2,6 +2,8 @@
本文档说明 WeKnora 中的**共享空间**功能,包括空间创建与加入、成员角色与权限、知识库与智能体共享规则、智能体停用机制,以及用户对知识库的最终访问权限计算方式。
> 共享空间解决「跨租户协作」,与单租户内的角色权限([租户 RBAC 说明](./RBAC说明.md))正交:一次跨租户的写操作必须同时穿过两道闸口。详见 RBAC 文档「与共享空间的关系」一节。
---
## 一、共享空间概述
+1 -1
View File
@@ -7,7 +7,7 @@ import "testing"
// DISABLE_REGISTRATION=true would block /auth/register at the handler layer
// but leave /auth/config reporting self_serve, so the frontend would keep
// showing the (broken) Register entry. Coercing registration_mode here keeps
// both gates in sync, and matches the docs/rbac.md "env always wins over
// both gates in sync, and matches the docs/RBAC说明.md "env always wins over
// YAML" rule.
func TestApplyAuthAndTenantDefaults_DisableRegistrationDrivesRegistrationMode(t *testing.T) {
cases := []struct {