feat: add tenant-scoped rule and permission management

This commit is contained in:
wren
2026-05-21 22:03:08 +08:00
parent a2c2bf1969
commit 1f1bccf3b3
193 changed files with 64463 additions and 1771 deletions
@@ -0,0 +1,830 @@
# 权限字段映射与 SQL 改造规范
> 适用范围:`leaudit-platform` 后端权限改造中的查询语句、资源详情、导出下载、统计聚合、管理列表
> 文档定位:为统一数据范围执行器的接入提供字段映射标准、SQL 拼接规范、资源回溯规范和反模式约束。
---
## 1. 文档目标
这份文档解决的是一个非常具体的问题:
统一数据范围执行器设计出来以后,后端各模块到底怎么把它安全、稳定地接到现有 SQL 上。
当前项目最大风险不是“不会写 scope”,而是:
1. 同一个模块里不同查询使用了不同字段口径
2. 列表、详情、下载、导出没有复用同一主资源边界
3. 各 service 自己拼 `1 = 0``region``created_by`,可维护性很差
4. 多表 join 时,究竟该按 `u.area` 还是 `d.region`,目前没有统一规范
所以这份文档的目标是:
1. 定义统一字段映射标准
2. 定义统一 alias 规范
3. 定义统一 scope 子句拼接方式
4. 定义详情、下载、导出等派生资源的“主资源回溯”规则
5. 定义改造时哪些 SQL 能保留,哪些必须重构
---
## 2. 当前代码里已经暴露出的典型问题
结合现有实现,已经确认有以下模式:
- `documentServiceImpl` / `govdocServiceImpl`
- 通过 `d.region` + `f.created_by` 控制范围
- `usageStatsServiceImpl`
- 有时按 `u.area`
- 有时按 `d.region`
- 有时还要按 `e.area_snapshot`
- `contractTemplateServiceImpl`
- 既有 `t.region`
- 又有 `t.created_by`
- 还混入“省级模板可见”
- `rbacAdminServiceImpl`
- 用户列表、组织树本质上按 `u.area`
这说明项目不是“没有规则”,而是规则散了。
---
## 3. 核心原则
## 3.1 先确定主资源,再确定 scope 字段
SQL 改造第一步不是拼条件,而是先回答:
- 这个接口的主资源是谁?
例如:
- 文档列表:主资源是 `document`
- 文档状态:主资源仍是 `document`
- 公文报告下载:主资源不是 `report_artifact`,而是 `govdoc document`
- RAG 文档分段列表:主资源不是 `segment`,而是 `dataset`
- 交叉评查提案导出:主资源不是 `proposal`,而是“用户参与的任务文档”
只有先确定主资源,scope 才不会跑偏。
## 3.2 范围条件必须挂在主资源口径上
同一个接口里如果出现多表:
- `document d`
- `document_file f`
- `sso_users u`
必须先规定:
- area 看哪个表
- self 看哪个表
不能在同一个模块里一会儿 `u.area`,一会儿 `d.region`,又没有解释。
## 3.3 列表、详情、删除、下载、导出必须继承同一套 scope
这条必须强制执行。
不允许出现:
- 列表按文档范围过滤
- 详情直接按 `id`
- 下载直接按附件 `id`
- 导出直接按 run `id`
凡是派生资源,都必须回溯主资源。
---
## 4. 统一字段映射标准
## 4.1 标准字段分类
建议统一按 5 类字段管理:
1. `area_field`
2. `creator_field`
3. `owner_field`
4. `user_field`
5. `public_field`
建议结构:
```python
@dataclass
class ScopeFieldMapping:
area_field: str | None = None
creator_field: str | None = None
owner_field: str | None = None
user_field: str | None = None
public_field: str | None = None
```
## 4.2 字段语义定义
### `area_field`
表示资源的地区归属字段。
例如:
- `d.region`
- `t.region`
- `dataset.area`
- `app.area`
- `u.area`
### `creator_field`
表示“创建该资源的用户”。
例如:
- `f.created_by`
- `t.created_by`
- `proposal.created_by`
### `owner_field`
表示“资源所有者”,和创建者不一定相同。
如果当前项目没有成熟 owner 模型,可以先不启用。
### `user_field`
用于用户本身或用户快照类数据。
例如:
- `u.id`
- `e.user_id`
### `public_field`
表示是否公共可见。
例如:
- `dataset.is_public`
- `app.is_public`
---
## 5. 各模块字段映射表
## 5.1 文档模块
主资源:`leaudit_documents d`
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `d.region` |
| `creator_field` | `f.created_by` |
| `owner_field` | 暂无 |
| `user_field` | `f.created_by` |
说明:
- 当前代码事实口径就是 `d.region + f.created_by`
- 文档的“自己数据”不建议改成 `d.created_by`,因为现有实现明显依赖文件记录
## 5.2 公文模块
主资源:`govdoc document / leaudit_documents d`
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `d.region` |
| `creator_field` | `f.created_by` |
| `user_field` | `f.created_by` |
说明:
- 公文 run、报告、原文都必须回溯到 `d.region``f.created_by`
## 5.3 使用统计模块
这块必须拆成两个口径。
### 用户口径统计
主资源:`sso_users u``usage_login_events e`
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `u.area``e.area_snapshot` |
| `user_field` | `u.id``e.user_id` |
### 文档口径统计
主资源:文档上传/评查事件
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `d.region` |
| `creator_field` | `f.created_by` |
| `user_field` | `f.created_by` |
说明:
- `areaScope=user``areaScope=document` 本质上是两套字段映射切换
- 不能在一个 builder 里靠字符串替换硬凑
## 5.4 合同模板模块
主资源:`contract_template t`
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `t.region` |
| `creator_field` | `t.created_by` |
| `public_field` | 暂无;如后续有省级公共模板逻辑,应单独建模 |
说明:
- 当前模块有“省级模板 + 本地区模板”可见语义
- 这不是 `is_public`
- 应在 `ContractTemplatePolicy` 中定义成“系统公共范围”,不要混同布尔公开字段
## 5.5 RBAC 用户管理
主资源:`sso_users u`
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `area_field` | `u.area` |
| `user_field` | `u.id` |
说明:
- 角色对象本身不按 area
- 但“查看哪个用户、给谁分配角色”按 `u.area`
## 5.6 RAG 模块
### 数据集
主资源:`dataset`
| 类型 | 字段 |
| --- | --- |
| `area_field` | `dataset.area` |
| `creator_field` | `dataset.created_by` |
| `public_field` | `dataset.is_public` |
### 聊天应用
主资源:`app`
| 类型 | 字段 |
| --- | --- |
| `area_field` | `app.area` |
| `public_field` | `dataset.is_public` 或显式 `app.is_public` |
说明:
- 如果 app 的公开性来自关联 dataset,就要在 policy 层明确写清,不要分散在查询里隐式推断
## 5.7 交叉评查
主资源:任务关系,而不是单表字段。
推荐映射:
| 类型 | 字段 |
| --- | --- |
| `user_field` | `tm.user_id` |
| `creator_field` | `proposal.created_by` |
说明:
- 交叉评查以 `RELATION` 模型处理
- 这块不强制要求 `area_field`
---
## 6. Alias 统一规范
统一执行器要稳定接入,SQL alias 必须统一。
建议标准:
| 资源 | 推荐 alias |
| --- | --- |
| 文档主表 | `d` |
| 文档文件表 | `f` |
| 用户表 | `u` |
| 审查运行表 | `r` |
| 统计登录事件表 | `e` |
| 合同模板表 | `t` |
| RAG 数据集 | `dataset` |
| RAG 应用 | `app` |
| 交叉评查任务 | `task` |
| 交叉评查成员 | `tm` |
| 交叉评查提案 | `proposal` |
目的不是强迫重命名所有 SQL,而是:
- 新增或重构 SQL 时尽量统一
- `QueryScopeBuilder``ModulePolicy` 才能更容易复用
---
## 7. 参数命名规范
当前代码里已经有:
- `requested_region`
- `scope_region`
- `scope_user_id`
- `requested_user_id`
建议固化为统一标准:
| 参数 | 含义 |
| --- | --- |
| `requested_area` | 请求方传入的地区过滤 |
| `scope_area` | 当前 scope 决策后的有效地区 |
| `requested_user_id` | 请求方显式筛选的用户 |
| `scope_user_id` | scope 决策后的当前用户 ID |
| `resource_id` | 主资源 ID |
| `visible_areas` | `PUBLIC_MIXED` 或公共范围列表 |
不建议再混用:
- `query_area`
- `user_area`
- `region`
- `scope_region`
除非当前模块确实已经固定使用 `region` 作为数据库字段名,而不是参数名。
建议规则:
- 参数命名一律使用 `area`
- 数据库字段保留 `region/area` 原名
即:
```sql
COALESCE(d.region, '') = :scope_area
```
而不是:
```sql
COALESCE(d.region, '') = :scope_region
```
这样更利于统一执行器复用。
---
## 8. 标准 SQL 子句模板
## 8.1 `ALL`
无地区限制时:
```sql
1 = 1
```
带请求地区过滤时:
```sql
COALESCE({area_field}, '') = :requested_area
```
## 8.2 `DEPT`
```sql
COALESCE({area_field}, '') = :scope_area
```
并且在进入 SQL 前先做规则:
-`requested_area` 非空且不等于 `scope_area`,直接拒绝或生成 `1 = 0`
## 8.3 `SELF`
```sql
{creator_field} = :scope_user_id
```
若请求还传了 `requested_user_id`
-`scope_user_id` 不相等时直接拒绝
## 8.4 `PUBLIC_MIXED`
```sql
(
COALESCE({area_field}, '') IN :visible_areas
OR {public_field} = TRUE
)
```
推荐 `visible_areas` 至少包含:
- 当前用户地区
- `省级`
- `''`
具体由 `RagPolicy` 决定。
## 8.5 `RELATION`
不建议硬编码为通用模板。
应由 `CrossReviewPolicy` 输出,例如:
```sql
EXISTS (
SELECT 1
FROM leaudit_cross_review_task_member tm
WHERE tm.task_id = task.id
AND tm.user_id = :scope_user_id
AND tm.deleted_at IS NULL
)
```
---
## 9. 主资源回溯规范
这是本轮 SQL 改造里最重要的一条。
## 9.1 什么叫主资源回溯
当接口操作的不是主资源表,而是主资源衍生物时,必须回溯到主资源做 scope 判断。
## 9.2 必须回溯的场景
### 文档状态
虽然接口直接拿文档 ID 列表查状态,但本质仍是文档资源。
必须回溯:
- `document`
- `file`
### 公文 run / report / original
不能只按:
- `run_id`
- `artifact_id`
- `document_id`
直接查。
必须回溯到:
- `govdoc document d`
- `original file f`
### RAG dataset document / segment
不能只按:
- `document_id`
- `segment_id`
直接做可见性判断。
必须回溯到:
- `dataset`
### 交叉评查 proposal export
不能只按 `DocumentId` 导出。
必须回溯到:
- 当前用户是否属于该任务关系链
---
## 10. 推荐接入方式
## 10.1 列表接口
推荐模式:
1. 构造基础查询
2. 获取 `PermissionDecision`
3.`QueryScopeBuilder` 生成 `scope_clause`
4.`scope_clause.sql` 注入 `WHERE`
5. 业务筛选条件作为附加条件继续追加
示意:
```python
decision = await permissionScopeFacade.require(...)
scope_clause = queryScopeBuilder.build_by_mapping(...)
where_clauses = [
"d.deleted_at IS NULL",
scope_clause.sql,
]
params = {
**scope_clause.params,
}
```
## 10.2 详情接口
推荐模式:
不要先查,再判断。
而要直接:
```sql
SELECT ...
FROM ...
WHERE id = :resource_id
AND {scope_clause.sql}
```
这样天然避免“查到了但后面忘记拒绝”的风险。
## 10.3 删除/更新接口
推荐两种方式:
### 方式 A:先用带 scope 的 SELECT 锁定资源
```sql
SELECT id
FROM ...
WHERE id = :resource_id
AND {scope_clause.sql}
FOR UPDATE
```
### 方式 B:直接带 scope 执行 UPDATE/DELETE
```sql
UPDATE ...
SET ...
WHERE id = :resource_id
AND {scope_clause.sql}
```
建议优先用方式 A,更利于错误提示和审计。
## 10.4 下载/导出接口
推荐模式:
1. 先回溯主资源并带 scope 查出合法记录
2. 再根据合法记录生成下载地址或导出文件
不允许:
1. 先按附件 ID 或 artifact ID 取到 OSS URL
2. 再事后补判断
---
## 11. 统计聚合改造规范
统计类 SQL 最容易出错,因为不是简单查明细。
## 11.1 先收 scope,再聚合
推荐:
```sql
WITH scoped_docs AS (
SELECT d.id, d.region, f.created_by
FROM ...
WHERE ...
AND {scope_clause.sql}
)
SELECT ...
FROM scoped_docs
GROUP BY ...
```
不推荐:
先全量聚合,再在外层做地区过滤。
原因:
- 易错
- 性能差
- `SELF` 范围很容易被漏掉
## 11.2 用户口径和文档口径必须拆分
不要写一个函数同时隐式支持:
- `u.area`
- `d.region`
- `e.area_snapshot`
建议明确:
- `build_user_area_scope_clause()`
- `build_document_area_scope_clause()`
- `build_login_snapshot_scope_clause()`
或者在 `UsageStatsPolicy` 内按口径分发。
---
## 12. 反模式清单
以下写法在本轮改造中应视为禁用或逐步清理对象。
## 12.1 反模式:角色名直接拼 SQL 范围
```sql
bool_or(r.role_key IN ('super_admin', 'provincial_admin'))
```
问题:
- 角色名直接耦合能力
- 新角色无法扩展
## 12.2 反模式:详情先查后判
```python
row = await session.execute(...)
if row and current_user["can_manage"]:
...
```
问题:
- 非法资源已经被读出来
- 派生接口最容易漏判
## 12.3 反模式:用 `1 = 0` 到处散落表达拒绝
`1 = 0` 可以作为最终 SQL 结果,但不应成为业务逻辑表达方式。
更推荐:
- 在决策层直接拒绝
- 或由 builder 明确返回 `denied clause`
## 12.4 反模式:同一模块 area 字段口径漂移
例如:
- 文档列表按 `u.area`
- 文档详情按 `d.region`
这种写法必须统一。
## 12.5 反模式:下载 URL 先取后判
尤其是:
- 原文下载
- 报告下载
- 导出文件
必须先判 scope,再产出 URL。
---
## 13. 索引建议
统一执行器接入后,范围过滤会更集中,需要补齐索引。
建议优先确认以下索引:
### 文档/公文
- `documents(region)`
- `document_files(created_by)`
- `document_files(document_id, created_by)`
### 用户
- `sso_users(area)`
- `sso_users(status, deleted_at, area)`
### 合同模板
- `contract_templates(region)`
- `contract_templates(created_by)`
### RAG
- `rag_datasets(area, is_public)`
- `rag_apps(area)`
### 交叉评查
- `task_member(task_id, user_id)`
- `proposal(document_id, created_by)`
说明:
- 索引不是这轮文档的主体,但如果没有,会直接影响统一执行器上线后的查询性能
---
## 14. 推荐代码组织
建议新增或统一下面这类方法:
```python
build_scope_clause_for_document(...)
build_scope_clause_for_user(...)
build_scope_clause_for_dataset(...)
resolve_primary_resource_scope(...)
```
更推荐的最终形态是:
```python
decision = await permissionDecisionService.decide(ctx)
scope_clause = modulePolicy.build_clause(ctx, decision)
```
这样业务 service 只负责:
1. 自身业务查询
2. 接入 scope 子句
3. 做结果组装
而不是自己解释权限模型。
---
## 15. 分模块改造建议
## 15.1 文档与公文
优先动作:
1. 抽出统一 `DocumentScopeMapping`
2. 统一 `d.region + f.created_by`
3. 所有详情/状态/run/report/download 统一回溯主文档
## 15.2 使用统计
优先动作:
1. 拆分用户口径和文档口径
2. 不再通过字符串替换实现 `area_snapshot`
3.`SELF` 范围明确化
## 15.3 合同模板
优先动作:
1. 把“省级模板可见”收敛为 policy
2. 列表/搜索/详情共享同一范围规则
## 15.4 RBAC 用户管理
优先动作:
1. 用户列表、组织树、角色分配统一按 `u.area`
2. 角色元数据接口不强加 area scope
## 15.5 RAG
优先动作:
1. `PUBLIC_MIXED` 统一化
2. dataset 子资源全部回溯 dataset
---
## 16. 最终要求
后续所有权限改造相关 SQL,应满足以下要求:
1. 能明确说清主资源是谁
2. 能明确说清 `area_field``creator_field` 是哪个
3. 能明确说清详情/下载/导出是否回溯主资源
4. 不再依赖角色名派生范围
5. 新增接口时可以直接挂到统一执行器,而不是复制旧判断
如果做不到这 5 条,即使逻辑暂时跑通,也不算完成“统一权限架构”的 SQL 层落地。