mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
本文档说明 WeKnora 中的**共享空间**功能,包括空间创建与加入、成员角色与权限、知识库与智能体共享规则、智能体停用机制,以及用户对知识库的最终访问权限计算方式。
|
||||
|
||||
> 共享空间解决「跨租户协作」,与单租户内的角色权限([租户 RBAC 说明](./RBAC说明.md))正交:一次跨租户的写操作必须同时穿过两道闸口。详见 RBAC 文档「与共享空间的关系」一节。
|
||||
|
||||
---
|
||||
|
||||
## 一、共享空间概述
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user