From 7ebb29cd3e0cfe29687965d59ff0549f19073d3d Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 21 May 2026 12:27:10 +0800 Subject: [PATCH] docs: add Chinese RBAC guide and link with shared space docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 2 +- docker-compose.yml | 2 +- docs/RBAC说明.md | 226 ++++++++++++++ docs/rbac.md | 377 ------------------------ docs/wiki/Home.md | 4 + docs/wiki/安全认证/RBAC说明.md | 112 +++++++ docs/wiki/安全认证/共享空间说明.md | 2 + docs/共享空间说明.md | 2 + internal/config/auth_legacy_env_test.go | 2 +- 9 files changed, 349 insertions(+), 380 deletions(-) create mode 100644 docs/RBAC说明.md delete mode 100644 docs/rbac.md create mode 100644 docs/wiki/安全认证/RBAC说明.md diff --git a/.env.example b/.env.example index 5627fc6b..4f3821a8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d06ab8b8..ad8e5333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 关闭限额(不建议共享部署使用) diff --git a/docs/RBAC说明.md b/docs/RBAC说明.md new file mode 100644 index 00000000..5b15c5a4 --- /dev/null +++ b/docs/RBAC说明.md @@ -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) diff --git a/docs/rbac.md b/docs/rbac.md deleted file mode 100644 index 836fe983..00000000 --- a/docs/rbac.md +++ /dev/null @@ -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. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d20b800a..fd676f58 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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 diff --git a/docs/wiki/安全认证/RBAC说明.md b/docs/wiki/安全认证/RBAC说明.md new file mode 100644 index 00000000..85f06501 --- /dev/null +++ b/docs/wiki/安全认证/RBAC说明.md @@ -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 角色匹配 diff --git a/docs/wiki/安全认证/共享空间说明.md b/docs/wiki/安全认证/共享空间说明.md index d56f7c09..25e4374a 100644 --- a/docs/wiki/安全认证/共享空间说明.md +++ b/docs/wiki/安全认证/共享空间说明.md @@ -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) — 导入的知识库可通过共享空间共享 diff --git a/docs/共享空间说明.md b/docs/共享空间说明.md index 3c511d45..94f9e157 100644 --- a/docs/共享空间说明.md +++ b/docs/共享空间说明.md @@ -2,6 +2,8 @@ 本文档说明 WeKnora 中的**共享空间**功能,包括空间创建与加入、成员角色与权限、知识库与智能体共享规则、智能体停用机制,以及用户对知识库的最终访问权限计算方式。 +> 共享空间解决「跨租户协作」,与单租户内的角色权限([租户 RBAC 说明](./RBAC说明.md))正交:一次跨租户的写操作必须同时穿过两道闸口。详见 RBAC 文档「与共享空间的关系」一节。 + --- ## 一、共享空间概述 diff --git a/internal/config/auth_legacy_env_test.go b/internal/config/auth_legacy_env_test.go index 66ce9b38..aa9a48ee 100644 --- a/internal/config/auth_legacy_env_test.go +++ b/internal/config/auth_legacy_env_test.go @@ -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 {