# IAM 权限系统架构标准

> **doc_type**: Standard
> **status**: Draft
> **last_verified**: 2026-04-12
>
> 本文档定义权限系统的目标架构，是所有模块实现权限相关功能的唯一参考。
> **设计参考**：若依（RuoYi）框架 @DataScope + AOP 模式

### 术语表（规则层统一用词）

| 术语 | 指代 | 备注 |
|---|---|---|
| `DataScope` | Layer 4 数据权限的总称；数据库 model 名同名 | 与 Layer 3 的"permission scope"（已废弃）严格区分 |
| `scopeType` | `DataScopeType` 枚举的大写常量值：`SELF` / `DEPARTMENT` / `DEPARTMENT_TREE` / `ORGANIZATION` / `REGION` / `ALL` / `CUSTOM` | 代码里用；文档里讨论"scope 类型"时指这个 |
| `DataScope.code` | 内置 DataScope 的小写唯一标识：`self_only` / `dept_only` / `dept_tree` / `org_all` / `region_scope` / `all` | 数据库列；`ALL` scopeType 的 code 是 `all`（全小写），不混淆 |
| `currentOrg` | 当前请求的组织上下文（由 `x-region-id` 或用户默认组织决定），挂在 `request.currentOrg` 上 | 代码标识符；中文叙述时用"当前组织"，代码级表述时用 `currentOrg` |
| `currentRegion` | 当前请求的区域上下文，挂在 `request.currentRegion` 上 | 同上 |
| `system principal` | 非人类主体（定时任务 / 队列消费者 / webhook 处理器）的身份标识 | 与用户 principal 区分；见 §5.3.13 |
| `system:admin` | 最高级权限码，可创建全局记录（`organizationId = null`）、豁免配置 ceiling 等 | Administrator 角色默认持有 |
| "作用域" | 仅指代 Layer 4 的 DataScope 空间范围 | 不再用于 Layer 3（Layer 3 只谈"权限码"） |

### 关键设计决策

| # | 决策 | 理由 |
|---|------|------|
| 1 | Guard 只管权限码，DataScope 管数据范围 | 职责分离，若依模式 |
| 2 | 不做端点级 ceiling；但做**配置级 ceiling**（配置 scope ≤ 自己 scope） | 端点级成本高，配置级防越权配置 |
| 3 | 规则层不分期，一次定义终态 | 避免"临时方案沦为长期债"的碎片化 |
| 4 | @DataScope 装饰器携带字段映射 | 声明在使用处，若依 alias 模式 |
| 5 | 写操作 Service 层 assertAccess | 读自动（Interceptor），写手动（需先查记录） |
| 6 | JWT payload 仅存身份标识，权限数据走 Redis 缓存 | 支持 ≤5min 撤权 SLA；避免 7d token 里缓存老 scope |
| 7 | 部门树递归 maxDepth=10 | 防环死循环 |
| 8 | 无 DataScope 配置时默认 SELF | 安全侧，不泄露数据 |
| 9 | CUSTOM 类型永久禁用 | 由决议 #10 推出，不是"暂不支持" |
| 10 | 身份维度归 DataScope，业务维度（时间/状态/金额）归 Service WHERE | DataScope 保持单一职责 |
| 11 | 读未授权返回 404（不可见即不存在），写未授权返回 403 | 隐私优先，对齐 GitHub/GitLab 主流实践 |
| 12 | 跨组织数据范围仅取当前组织合并 | 多租户隔离原则 |
| 13 | DataScope 配置变更必审计 | 合规强需求，黑箱变配置 → 可追溯 |
| 14 | 异步任务必须显式携带 actor（user / system principal），无 actor 抛异常 | 避免 Confused Deputy 类隐式提权 |
| 15 | 委托（Delegation）作为 DataScope 一等公民，仅一级，必审计 | 经理休假场景必备；避免链式委托的复杂度 |
| 16 | 用户-角色绑定 90 天内必须定期 Access Review | SOC2 / ISO27001 合规要求 |
| 17 | IDOR 零容忍：静态契约检查 + 集成测试 + PR Review 三重防护 | 漏一个就是数据泄露 |

---

## 零、威胁模型与假设

规则层先讲清楚"防什么、不防什么、前提是什么"，所有后续设计决策以此为判断依据。

### 0.1 防护范围（IN SCOPE）

| 威胁 | 对应防护 |
|------|----------|
| 认证用户间水平越权（IDOR：用任意 id 改/删他人数据） | 读 Interceptor 自动过滤 + 写 `assertAccess` + 写路径返回 404/403 区分（决议 #11） |
| 权限码绕过（访问未授权功能） | `PermissionsGuard` 全局强制，无装饰器端点拒绝 |
| 跨组织数据泄露（租户 A 看到租户 B 的数据） | Layer 2 `RegionGuard` + Layer 4 `organizationId` 过滤 + 跨组织不合并 scope（决议 #12） |
| 已离职员工持续访问 | Redis 权限缓存 TTL ≤ 5min + 主动 invalidate JTIs（决议 #6） |
| 越权配置 DataScope（管理员给自己或别人配 `ALL`） | 配置时校验"≤ 自己当前 scope" + 配置变更审计（决议 #2, #13） |
| 非人类主体的数据访问（定时任务、webhook） | 显式 Administrator 身份 + 必记审计日志 |

### 0.2 不防护（OUT OF SCOPE）

| 威胁 | 理由 / 替代兜底 |
|------|----------------|
| SQL 注入 | Prisma 全参数化查询兜底，不在 IAM 层额外防护 |
| 聚合/统计攻击（看不到单条但能看到 count/avg 反推） | 业务自评估；若敏感统计需保护，由模块在 Service 层自加防护，不入 IAM 规则 |
| 侧信道（FK 下拉菜单存在性推断、错误信息差异） | 不在当前规则防护目标中；UUID 难穷举已缓解大部分风险 |
| 服务器层入侵（直接访问数据库、绕过应用层） | 依赖系统安全层（网络隔离、DB 访问控制） |
| 零信任（管理员角色被入侵） | IAM 规则假设管理员角色可信；内控需要另行设计 |
| 跨应用 Token 共享 | JWT 不含应用标识，跨应用复用由部署层隔离 |

### 0.3 前提假设

以下任一假设被打破，则本规则失效：

1. JWT 签名密钥未泄露（HS256 ≥ 256bit 或使用 RS256）。
2. 应用层独占数据库访问，不存在绕过应用的直接 SQL 入口（BI 工具等只读专用账号除外）。
3. Redis 可用（权限缓存失效会退化为拒绝访问，不退化为跳过检查）。
4. 管理员角色是可信内部人员；Administrator 角色仅用于系统初始化与紧急维护。
5. 所有业务表的 `organizationId` 填充由系统根据当前上下文完成，不接受客户端传值（写操作规则，见 §5.3.4「`ORGANIZATION` scope 对 `organizationId = NULL` 的全局表」及 §8 禁止事项 #14）。

---

## 一、总体架构：四层模型

```
┌─────────────────────────────────────────────────────────┐
│  Layer 1: 身份认证（Authentication）                       │
│  "你是谁" — JWT / LDAP / Entra ID / 钉钉                  │
├─────────────────────────────────────────────────────────┤
│  Layer 2: 组织上下文（Organization Context）               │
│  "你在哪" — 租户 → 组织 → 部门树 → 岗位 → 用户             │
├─────────────────────────────────────────────────────────┤
│  Layer 3: 功能权限（Functional Permission）               │
│  "你能做什么" — RBAC 角色 + 权限码 + 作用域                 │
├─────────────────────────────────────────────────────────┤
│  Layer 4: 数据权限（Data Scope）                          │
│  "你能看到哪些数据" — 行级过滤 + 列级脱敏                   │
└─────────────────────────────────────────────────────────┘
```

请求处理流程：

```
Request
  → [JwtAuthGuard]       Layer 1: 验证 token，加载用户身份
  → [RegionGuard]        Layer 2: 验证区域访问权限，注入 currentRegion
  → [PermissionsGuard]   Layer 3: 验证功能权限 + 作用域
  → Controller
  → Service              Layer 4: 数据权限过滤（查询条件注入）
  → Response
```

---

## 二、Layer 1: 身份认证

### 2.1 支持的身份源

| 身份源 | 实现状态 | 说明 |
|--------|---------|------|
| LOCAL | ✅ 已实现 | 本地账号 + bcrypt 密码哈希 |
| LDAP/AD | ✅ 已实现 | 企业目录集成 |
| Entra ID | ✅ 已实现 | Microsoft SSO |
| 钉钉 | ✅ 已实现 | 扫码登录 |

### 2.2 Token 策略（规则要求）

| 配置项 | 要求 | 说明 |
|--------|------|------|
| Access Token 有效期 | **30d**（env: `JWT_ACCESS_TTL`） | 详见下方变更说明 |
| Refresh Token | **30d**（env: `JWT_REFRESH_TTL`），存 Redis，支持主动撤销 | 用于静默续期 |
| Token 黑名单 | **Redis**（JTI 做 key，TTL = token 剩余有效期） | 持久化，支持集群 |
| JTI (Token ID) | **必须有**，每次签发唯一 | 用于黑名单精确匹配与主动失效 |
| JWT payload 内容 | **仅 `userId` / `exp` / `jti`**，不得携带权限或 DataScope 数据 | 权限数据走 Redis，支持 ≤5min 撤权 SLA |
| 用户 active JTI 列表 | Redis key `user:${userId}:active_jtis`，用于解雇场景一键失效所有 token | 支持撤权实时性 |

#### Access Token 30min → 30d 变更说明（2026-05-08）

历史规则定义 Access TTL = 30min，意图是"减少泄露窗口"。但实际运行暴露两个问题：

1. **低频访问场景必走 refresh**：站点考勤打卡这类一天 2 次（早签到、晚签出，间隔 8+ 小时）的入口，access token 每次都过期 → 强制走 `/auth/refresh`。任何 refresh 失败概率（钉钉/企业微信内嵌浏览器丢 localStorage、网络抖动、PM2 重启瞬间、JTI 撤销窗口竞态）都直接转化为 401，扩大失败面而不是缩小。
2. **撤权 SLA 与 access TTL 解耦**：JWT payload **不带**权限/DataScope（决议 #6），权限数据走 Redis 5min 缓存。撤权链路是"清 Redis + 加黑名单 + 删 active_jtis"，**不依赖 access token 自然过期**。短 TTL 不会让撤权更快，反而增加正常用户的失败率。

新规则：Access TTL = 30d（与 Refresh TTL 对齐），实际 token 寿命由 Redis 黑名单 / 主动撤销 / 用户密码改动控制，不再依赖 JWT 自然过期。

**ENV 命名固定为 `JWT_ACCESS_TTL` / `JWT_REFRESH_TTL`**（历史名 `JWT_EXPIRATION` / `JWT_EXPIRES_IN` / `JWT_REFRESH_EXPIRES_IN` 已废弃，代码不再读取，CI `check-env-coverage.sh` 也不再放过这些名字）。

### 2.3 登录安全（规则要求）

| 能力 | 要求 |
|------|------|
| 登录限流 | **30s 内 ≤ 5 次**（@nestjs/throttler） |
| 密码策略 | 复杂度（8 位+大小写+数字）+ 历史检查（至少不同于最近 3 次） |
| MFA | TOTP，敏感操作（密码修改、权限变更、数据导出）触发 |
| 会话管理 | 多设备并发允许，异地登录触发二次验证 |
| dev-email-login | 受 `NODE_ENV + DEPLOY_ENV` 双重保护，仅 `development` 可用 |

### 2.4 关键文件

```
backend/src/modules/organization/auth/
├── auth.controller.ts          # 登录/注册/登出端点
├── auth.service.ts             # 认证核心逻辑
├── auth.module.ts              # 模块注册
├── guards/
│   ├── jwt-auth.guard.ts       # JWT 验证（全局注册）
│   └── permissions.guard.ts    # 权限验证（全局注册）
└── strategies/
    └── jwt.strategy.ts         # JWT 解析 + 用户加载
```

---

## 三、Layer 2: 组织上下文

### 3.1 组织结构模型

```
Region (CN / US / UAE)
  └─1:N─→ Organization (公司/法人实体)
              └─1:N─→ Department (部门树, parentId 自引用)
                          └─M:N─→ User (通过 UserDepartment)
                                     ├── isPrimary: 主部门标记
                                     ├── positionId: 岗位
                                     └── managerId: 直属上级
```

### 3.2 关键规则

- **一人多部门**：用户可属于多个部门，通过 `UserDepartment` 关联
- **主部门**：`isPrimary = true`，用于确定默认数据可见范围和审批链路
- **软离职**：`leftAt` 字段标记离开部门，不删除记录
- **组织隔离**：`UserRole.organizationId` 实现角色按组织分配

### 3.3 区域权限

| 区域 | 权限码 | 说明 |
|------|-------|------|
| 中国 | `region:cn:access` | 访问中国区数据 |
| 美国 | `region:us:access` | 访问美国区数据 |
| 阿联酋 | `region:uae:access` | 访问阿联酋区数据 |
| 全部 | `region:*:access` | 通配符，访问所有区域 |

区域通过 `x-region-id` 请求头传递，RegionGuard 验证权限后注入 `request.currentRegion`。

### 3.4 关键文件

```
backend/prisma/schema/corp_hr.prisma    # Organization, Department, UserDepartment
backend/prisma/schema/platform_iam.prisma  # User, UserRole
backend/src/common/guards/region.guard.ts  # 区域守卫
```

---

## 四、Layer 3: 功能权限

### 4.1 RBAC 模型

```
User ──M:N──→ Role ──M:N──→ Permission
       │                        │
   UserRole              RolePermission
   (带 organizationId)
```

### 4.2 角色体系

| 类型 | 说明 | 分配方式 |
|------|------|---------|
| **系统角色** | 全局固定角色 | UserRole.organizationId = null |
| **组织角色** | 按组织分配 | UserRole.organizationId = 具体组织 |
| **流程角色** | 审批流动态解析 | WorkflowRole 表，规则引擎解析 |

### 4.3 内置角色

| 角色 | Code | 定位 | 权限策略 |
|------|------|------|---------|
| Administrator | Administrator | 超级管理员 | 通配符 `*:*`，跳过所有权限检查 |
| HrManager | HrManager | HR 管理 | 用户/部门/岗位/绩效全部权限 |
| DepartmentManager | DepartmentManager | 部门经理 | 部门级读写 + 审批 + 绩效 |
| Leader | Leader | 管理层 | 只读 + 审批查看 + 团队绩效 |
| Employee | Employee | 普通员工 | 个人数据读写 + 基础审批 |
| FinanceApprover | FinanceApprover | 财务审批 | 审批 + 备件审批 |
| MeetingManager | MeetingManager | 会议管理 | 会议出勤全部权限 |
| FormDesigner | FormDesigner | 表单设计 | 表单设计/发布权限 |
| FormAdmin | FormAdmin | 表单管理 | `form:*` 通配符 |
| ApprovalAdmin | ApprovalAdmin | 审批管理 | `approval:*` 通配符 |
| PARTS | PARTS | 备件管理 | 备件全部权限 |

### 4.4 权限码规范

**格式**：`resource:action`

```
resource = 模块资源名，snake_case
action   = 操作动词

示例：
  user:create          创建用户
  user:read            读取用户
  purchase_order:approve  审批采购单
  knowledge_base:admin    知识库管理
```

**通配符**：
- `resource:*` — 该资源的所有操作
- `*:*` — 所有资源的所有操作（仅 Administrator）
- `resource:*:action` — 嵌套资源通配（如 `form:*:create`）

**命名约定**：
- resource 使用 snake_case（如 `knowledge_base`，不是 `knowledgeBase`）
- action 使用小写动词（create / read / update / delete / list / manage / admin / approve / export）
- 模块级管理用 `admin`，具体操作用具体动词

**最低 DataScope 建议（不强制耦合）**：

| 权限码类型 | 建议最低 DataScope | 示例 |
|---|---|---|
| `*:admin` / `*:manage` | **≥ ORGANIZATION** | `user:admin` 建议配 `org_all` |
| `*:approve` / `*:export` | **≥ DEPARTMENT_TREE** | `purchase_order:approve` 建议配 `dept_tree` 或更宽 |
| `*:update` / `*:delete` / `*:create` | ≥ DEPARTMENT | 一般业务操作 |
| `*:read` / `*:list` | 无要求（可以是 SELF） | 个人视图合法 |

**规则**：不强制 Guard 层校验（规则系统保持正交——合法场景确实存在，如"我只管理自己的数据"）。但**角色权限矩阵审核时**，若高权限码配置低于建议最低 scope，必须在模块 PRD「角色权限矩阵」中记录理由；PR review 必须 push back 无理由的不匹配组合。

### 4.5 Guard 职责（简化后）

Guard **只做功能权限检查**，不做数据范围校验。数据范围由 Layer 4 DataScope 负责。

> **设计决策**：参考若依（RuoYi）框架模式——功能权限和数据权限完全分层，互不干扰。
> Guard 不设 ceiling（端点级上限），数据范围完全由角色的 DataScope 配置驱动。
> 防配错通过审计日志和配置审核解决，不靠代码兜底。

### 4.6 装饰器

统一使用 `@RequirePermissions`，**不再使用带 Scope 的变体**：

```typescript
// ✅ 正确：只声明权限码
@RequirePermissions('user:read')

// ❌ 废弃：不再使用带 Scope 的装饰器
@RequireOrganizationPermissions('user:read')   // → 改为 @RequirePermissions
@RequireDepartmentPermissions('user:read')     // → 改为 @RequirePermissions
@RequireGlobalPermissions('system:admin')      // → 改为 @RequirePermissions
@RequirePermissionsWithScope(...)              // → 改为 @RequirePermissions
```

数据范围由 Layer 4 的 `@DataScope` 装饰器 + `DataScopeService` 处理。

### 4.7 Guard 执行链

全局注册于 `app.module.ts`（APP_GUARD），所有请求自动经过：

```
1. JwtAuthGuard     — 验证 JWT，加载 request.user
                      @Public() 端点跳过
2. RegionGuard      — 验证区域权限，注入 currentRegion
                      @SkipRegionCheck() 端点跳过
3. PermissionsGuard — 仅验证功能权限码（不做数据范围校验）
                      无 @RequirePermissions 的端点直接放行
                      Administrator 角色跳过
```

**强制规则**：
- **每个端点必须有 @RequirePermissions 或 @Public()**
- 无装饰器 = 认证了但没鉴权（仅 JWT 保护），不安全
- 不允许使用 @UseGuards 在控制器上重复注册全局 guard（冗余）
- 不允许使用 @Roles 装饰器（已废弃）
- 不允许使用 @RequireOrganizationPermissions 等带 Scope 的装饰器（已废弃）

### 4.7 关键文件

```
backend/src/common/decorators/
├── permissions.decorator.ts     # @RequirePermissions 及变体
├── roles.decorator.ts           # @Roles（已废弃）
└── public.decorator.ts          # @Public

backend/src/modules/organization/auth/guards/
├── permissions.guard.ts         # 权限 + 作用域验证
└── roles.guard.ts               # 角色验证（已废弃）

backend/prisma/seeds/
├── permissions.seed.ts          # 权限码定义（244+）
├── roles.seed.ts                # 角色定义 + 权限映射
└── workflow-roles.seed.ts       # 流程角色定义
```

---

## 五、Layer 4: 数据权限

> **设计参考**：若依（RuoYi）框架的 `@DataScope` + AOP 模式。
> **核心原则**：功能权限（Layer 3）和数据权限（Layer 4）完全分层，互不干扰。

### 5.1 Layer 3 与 Layer 4 的分工

| | Layer 3（PermissionsGuard） | Layer 4（DataScopeService） |
|---|---|---|
| **问题** | 你**能不能**做这个操作 | 你能操作**哪些数据** |
| **执行位置** | Guard 层（请求进入 Controller 前） | Service 层（查询数据库时） |
| **依据** | 权限码（`resource:action`） | 角色的 DataScope 配置 |
| **配置方式** | 代码中装饰器声明 | 数据库配置（可运行时调整） |

Layer 3 通过后请求才进入 Layer 4。两者不可互相替代。

Guard **不做数据范围校验**，也**不设端点级天花板**。数据范围完全由角色的 DataScope 配置驱动。

### 5.2 数据权限三种类型

| 类型 | 解决的问题 | 示例 |
|------|-----------|------|
| **行级权限** | 用户能看到哪些记录 | 部门经理只看本部门的采购单 |
| **字段级权限** | 用户能看到哪些字段 | 非财务人员看不到成本价 |
| **脱敏** | 敏感字段部分遮挡 | 手机号显示为 138****1234 |

三类均为规则层定义的必备能力，实施进度见 `09-iam-implementation-todo.md`；具体字段配置项（哪些字段脱敏、给谁看）由业务需求驱动，基础设施必须先就位。

### 5.3 行级权限：DataScope 模型

#### 5.3.1 数据模型

```prisma
// platform_iam.prisma

model DataScope {
  id          String   @id @default(uuid())
  code        String   @unique           // "dept_only", "org_all"
  name        String                     // "仅本部门数据"
  scopeType   DataScopeType
  rules       Json?                      // CUSTOM 类型时使用
  isBuiltIn   Boolean  @default(true)
  
  roleScopes  RoleDataScope[]

  @@map("data_scopes")
  @@schema("platform_iam")
}

model RoleDataScope {
  id          String    @id @default(uuid())
  roleId      String    @map("role_id")
  dataScopeId String    @map("data_scope_id")
  resource    String    // "*" = 全部, "purchase_order" = 具体资源
  
  role        Role      @relation(fields: [roleId], references: [id])
  dataScope   DataScope @relation(fields: [dataScopeId], references: [id])
  
  @@unique([roleId, dataScopeId, resource])
  @@map("role_data_scope_rel")
  @@schema("platform_iam")
}

enum DataScopeType {
  SELF              // 仅创建人自己的数据
  DEPARTMENT        // 用户所在部门
  DEPARTMENT_TREE   // 用户所在部门及其所有下属部门
  ORGANIZATION      // 用户所在组织
  REGION            // 用户所在区域
  ALL               // 全部数据
  CUSTOM            // 永久禁用 — 历史标识，运行时抛 NotImplementedException
}
```

> **CUSTOM 永久禁用（设计边界，非阶段性妥协）**：
> 由职责边界规则（§5.3.1.2）推出——DataScope 只管**身份维度**，业务维度走 Service WHERE。CUSTOM 的需求场景（按时间窗/状态/金额过滤）本质都是业务维度，不应进 DataScope。
>
> - `CUSTOM` 常量保留在 enum 中作为历史标识（避免迁移改动）；
> - `DataScopeService` 遇到 CUSTOM 类型**永久抛 `NotImplementedException`**；
> - seed / UI 永久禁止创建 CUSTOM 类型的 DataScope。
>
> **新"身份维度"扩展路径**：若出现按仓库 / 项目 / 客户等身份维度隔离需求，加新枚举值（如 `WAREHOUSE` / `PROJECT`）并扩展 `DataScopeService` 与 `@DataScope` 默认字段映射。
>
> **"业务维度"处理**：时间/状态/金额等过滤直接写在 Service 层业务 WHERE 中，与 DataScope 过滤通过 Prisma `{ ...businessFilter, ...dataScopeFilter }` 合并。

#### 5.3.1.2 职责边界：身份维度 vs 业务维度

DataScope 只管**身份维度**，业务维度由 Service 层 WHERE 自行处理。

| 维度类型 | 管辖层 | 例子 |
|---|---|---|
| **身份维度** | DataScope | 谁（SELF）/ 哪个部门 / 哪个组织 / 哪个区域 |
| **业务维度** | Service 业务 WHERE | 时间窗 / 记录状态 / 金额阈值 / 标签 / 类别 |

**合并方式**：
```typescript
// Service 层
const dataScopeFilter = request.dataScopeFilter; // Interceptor 注入的身份维度 WHERE
const businessFilter = {
  status: 'PUBLISHED',            // 业务维度：只看已发布
  createdAt: { gte: last30Days }, // 业务维度：最近 30 天
};

return prisma.purchaseOrder.findMany({
  where: { ...businessFilter, ...dataScopeFilter },
});
```

**禁止**：不得在 DataScope rules 中表达业务维度条件。即便 CUSTOM 被开放，这种用法仍被拒绝。

#### 5.3.1.3 Resource 命名规范

`RoleDataScope.resource` 和 `@DataScope(resource)` 中的 resource 字段必须遵守：

**命名风格**：
- snake_case 名词**单数**（`user` / `part` / `purchase_order` / `meeting` / `form_template`）
- 不加模块前缀（不是 `iam.user`，就 `user`）
- 多词用下划线（`purchase_order`）

**粒度：以 Prisma model 为基准**
- 通常 1 个 model = 1 个 resource
- **附属资源跟随父资源**（判断标准：生命周期是否依附父资源）：
  - `Comment` on purchase_order → `resource = 'purchase_order'`（不单独建 `comment` resource）
  - `Attachment` on meeting → `resource = 'meeting'`
- 独立生命周期的子实体仍单独建 resource（如 `Position` 独立于 `Department`）

**一致性约束**：
- resource 名必须在 `permissions.seed.ts` 里至少出现在一条权限码（`${resource}:${action}`）中
- DataScope 用到的 resource 必须在至少一个 `@DataScope` 装饰器或 `RoleDataScope` 记录里被引用
- 未被权限码覆盖的 resource 名视为无效配置，seed 验证脚本拒绝写入

#### 5.3.2 内置 DataScope

| code | scopeType | 说明 |
|------|-----------|------|
| `self_only` | SELF | 仅本人创建的数据 |
| `dept_only` | DEPARTMENT | 仅本部门数据 |
| `dept_tree` | DEPARTMENT_TREE | 本部门及下属部门数据 |
| `org_all` | ORGANIZATION | 本组织所有数据 |
| `region_scope` | REGION | 当前区域数据 |
| `all` | ALL | 全部数据（跨组织） |

#### 5.3.3 角色-DataScope 绑定示例

| 角色 | 资源 | DataScope | 效果 |
|------|------|-----------|------|
| Employee | * | self_only | 所有模块只看自己的数据 |
| DepartmentManager | * | dept_tree | 看本部门及下属部门的数据 |
| HrManager | user | org_all | 看本组织所有用户 |
| HrManager | parts | dept_only | 看本部门备件 |
| Administrator | * | all | 看全部数据 |
| PARTS | parts | org_all | 看本组织所有备件 |

同一角色对不同资源可以有不同的 DataScope。

#### 多角色合并规则（按 resource 独立合并 + 仅当前组织）

用户持有多个角色时，合并规则包含两个维度：**组织维度**和 **resource 维度**。

**(1) 组织维度：仅当前组织参与合并**

多租户场景下，用户可能在多个组织持有不同角色。合并 DataScope 时**只考虑当前组织内的角色**，跨组织的身份**不**参与合并。

- 当前组织由 `x-region-id` / `currentRegion` 上下文确定
- 系统级角色（`UserRole.organizationId = null`）对所有组织生效，参与每个当前组织的合并
- 实现：`request.user.dataScopes` 按组织分层存储，请求时按 currentOrg 切片

**理由**：多租户核心原则是数据隔离。若 A 组织的 Employee 身份参与 B 组织的 scope 合并，会造成意外越权或意外权限降级。

**(2) resource 维度：每个 resource 独立合并，各自取最宽**

合并时 `*` 兜底行与精确匹配行都进入候选池，一起取最宽。

**最宽优先级**：`ALL > ORGANIZATION > DEPARTMENT_TREE > DEPARTMENT > SELF`

**示例**：小王在组织 A 同时持有 HrManager + DepartmentManager：

| | HrManager 贡献 | DepartmentManager 贡献 | 合并结果 |
|---|---|---|---|
| 查 `user` | `org_all`（精确匹配） | `dept_tree`（`*` 兜底） | **org_all** |
| 查 `parts` | `dept_only`（精确匹配） | `dept_tree`（`*` 兜底） | **dept_tree** |
| 查其它 | 无精确匹配，暂无 `*` | `dept_tree`（`*` 兜底） | **dept_tree** |

**关键点**：
- 不是"全局最宽"（否则小王查 parts 时会被拉升到 org_all，越权）。
- 每个角色的 `*` 兜底对每个具体 resource 都贡献一次候选。
- 无任何候选时默认 `SELF`（安全侧兜底，见 §0.3 前提假设）。

#### 5.3.3.1 配置 ceiling：配置 scope ≤ 自己 scope

**规则**：管理员给任意角色配置 DataScope 时，必须满足——**配置者自己对该 resource 的当前 scope ≥ 要配置的 scope**，否则拒绝。

| 场景 | 合法性 |
|---|---|
| HrManager（user → org_all）给 NewRole 配 user → dept_only | ✅ 合法（dept_only < org_all） |
| HrManager（user → org_all）给 NewRole 配 user → all | ❌ 拒绝（all > org_all） |
| DepartmentManager（* → dept_tree）给 NewRole 配 user → org_all | ❌ 拒绝（无精确配置，`*` 兜底 dept_tree < org_all） |
| Administrator（* → all） | ✅ 可配任何 scope |

**实现要求**：
- 校验点：角色管理模块的 "分配 DataScope" 接口（Service 层）
- 校验算法：复用 `DataScopeService` 的合并逻辑，算出配置者对该 resource 的当前有效 scope，再跟要配置的 scope 做优先级比较
- Administrator 角色豁免此校验（`*:admin` 权限码 + 系统级角色，否则无法 bootstrap）

**理由**：防止横向越权配置——任何有角色管理权限的人都不应该能配出超过自己 scope 的权限。

#### 5.3.3.2 DataScope 配置变更必审计

对以下表的任何增/删/改，必须记 AuditLog：
- `platform_iam.data_scopes`
- `platform_iam.role_data_scope_rel`
- `platform_iam.role_permission_rel`
- `platform_iam.user_role_rel`（角色分配）

**审计字段**：
```typescript
{
  actor: userId,        // 谁改的
  action: 'CREATE' | 'UPDATE' | 'DELETE',
  resource: 'RoleDataScope' | 'DataScope' | ...,
  before: { ... },      // 改动前状态（UPDATE / DELETE）
  after: { ... },       // 改动后状态（CREATE / UPDATE）
  timestamp: ISO8601,
  ip, userAgent,
}
```

**合规意义**：SOC2 / ISO27001 等审计要求对权限配置变更可追溯；黑箱改配置 → 读数据 → 改回来的操作必须留痕。

#### 5.3.4 @DataScope 装饰器与字段映射

不同业务表的字段名不同（有的叫 `createdById`，有的叫 `organizerId`），因此需要在使用处声明字段映射：

```typescript
// 默认映射（大多数表）— 不需要额外声明 fields
@Get()
@RequirePermissions('purchase_order:read')
@DataScope('purchase_order')
async findAll() { ... }
// → SELF 用 createdById, DEPARTMENT 用 departmentId, ORGANIZATION 用 organizationId

// 自定义映射（字段名不同的表）
@Get()
@RequirePermissions('meeting_attendance:read')
@DataScope({
  resource: 'meeting_attendance',
  fields: { userId: 'organizerId' }  // SELF 时用 organizerId
})
async findAll() { ... }
```

默认字段映射与 WHERE 语义：

| scopeType | 默认字段 | 生成的 WHERE | 说明 |
|-----------|---------|-------------|------|
| SELF | `createdById` | `createdById = currentUser` | 创建人 ID |
| DEPARTMENT | `departmentId` | `departmentId IN (user 所有 active 部门)` | 多部门语义：并集，见下 |
| DEPARTMENT_TREE | `departmentId` | `departmentId IN (所有 active 部门各自子树的并集)` | 子树递归 maxDepth=10 |
| ORGANIZATION | `organizationId` | `organizationId = currentOrg OR organizationId IS NULL` | 全局表透明可见，见下 |
| REGION | `regionId` | `regionId = currentRegion` | 当前区域 |
| ALL | — | （不注入 WHERE，返回全部） | 跨组织，仅 Administrator / system:admin |

##### 多部门用户的 `DEPARTMENT` 语义

用户可通过 `UserDepartment` 关联多个部门（`isPrimary` 标记主部门）。

**规则**：`DEPARTMENT` scope 取用户**所有 `leftAt IS NULL` 的部门的并集**（`IN (...)` 语义），不是只看主部门。

**理由**：多部门设计的业务含义就是"同时属于多个部门"，只看主部门会跟业务直觉冲突。

**已离职部门**：`leftAt != null` 的部门不计入，避免离职后继续看得到。

**主部门用途**：仅用于"默认归属"（新建记录挂哪个部门），不参与 DataScope 合并。

##### `ORGANIZATION` scope 对 `organizationId = NULL` 的全局表

**规则**：`ORGANIZATION` scope 的 WHERE 为 `organizationId = currentOrg **OR** organizationId IS NULL`。

**效果**：
- 业务表（`organizationId` 非空）按 currentOrg 过滤
- 全局表（`organizationId IS NULL`，如字典/内置配置）对所有组织可见

**配套写路径防护（重要）**：

- **服务端强制覆盖**：写操作（创建/更新）时，服务端**完全忽略**客户端提交的 `organizationId` 字段，直接用 `currentOrg` 覆盖。不是"拒绝传 null"这一条，而是"客户端传什么都不认，服务端自己填"。理由：拒绝式校验容易有遗漏，覆盖式防护彻底。
- **创建全局记录的专用路径**：显式创建 `organizationId = null` 的记录（字典表 / 系统配置）必须走 `POST /system/...` 等专用接口，该接口用 `@RequirePermissions('system:admin')` 保护。普通业务写路径永远不产生全局记录。
- **assertAccess 对全局记录的行为**：写操作遇到 `record.organizationId = null`，默认拒绝，除非调用者持有 `system:admin` 权限码。

**ORGANIZATION scope 用户对全局表的能力矩阵**：

| 操作 | 业务记录（`organizationId = currentOrg`） | 全局记录（`organizationId = null`） |
|---|---|---|
| 读（`findUnique` / `findMany`） | ✅ | ✅（WHERE 的 `OR IS NULL` 兜底） |
| 写（`create`） | ✅（系统填 `currentOrg`） | ❌（需走 `system:admin` 专用路径） |
| 写（`update` / `delete`） | ✅（assertAccess 通过） | ❌（assertAccess 拒绝，除非 `system:admin`） |

**UX 提示**：ORGANIZATION scope 用户看到全局记录但点编辑报 403 是**预期行为**，前端应：
- 列表/下拉里识别全局记录（如带"系统内置"标签）
- 编辑按钮对全局记录禁用或隐藏（由 `organizationId === null` 判断）
- 兜底：即使按钮露出，后端 403 必须带清晰错误码 `SYSTEM_RECORD_READ_ONLY`

**理由**：若 WHERE 只用等号，全局表对所有 ORGANIZATION scope 查询都查不到，UI 下拉永远是空；不允许 `IS NULL` 兜底会逼着每张全局表特殊处理。同时写路径不受此宽松规则影响，避免攻击者借 null 越权写入。

> **规则来源**：新建业务表必须按此命名约定建列（含 `createdById`、`organizationId` 强制），详见 `docs/standards/04-database-architecture.md`「标准字段」。命中该约定的表 `@DataScope('xxx')` 零配置工作；存量非标表用 `fields` 映射，不强制改造。

#### 5.3.5 权限与 DataScope 加载：Redis 缓存 + ≤5min 撤权 SLA

**JWT 瘦身**：JWT payload **只携带身份标识**（`userId / exp / jti`），不得携带权限码或 DataScope 配置。

**权限数据来源：Redis 缓存**。每次 JWT validate 时：

```
1. 从 JWT 拿到 userId
2. 读 Redis key `user:${userId}:auth`
   - 命中：直接用
   - 未命中：从 DB 加载（深 join roles → permissions + dataScopes）并按组织分层结构化后写入 Redis
3. 按 currentOrg 切出当前组织的权限与 dataScopes，挂到 request.user
```

**Redis 数据结构**（按组织分层，支持决议 #12 跨组织仅当前合并）：

```json
{
  "key": "user:${userId}:auth",
  "ttl": 300,   // 5min
  "value": {
    "systemRoles": { "permissions": [...], "dataScopes": [...] },
    "orgRoles": {
      "${orgId_A}": { "permissions": [...], "dataScopes": [...] },
      "${orgId_B}": { "permissions": [...], "dataScopes": [...] }
    }
  }
}
```

**撤权 SLA**：
- **自然失效**：Redis TTL = 5min，最长延迟 5 分钟
- **主动失效**：
  - 角色/scope 配置变更 → `DEL user:${affectedUserId}:auth`
  - 员工解雇 → `DEL user:${userId}:auth` + invalidate 所有 `user:${userId}:active_jtis` 列表中的 token

**Redis 故障退化**：Redis 不可用 → DataScopeService 直接拒绝访问（`ForbiddenException`），**不退化为跳过检查**。这是 §0.3 前提假设 #3 的强约束。

**Redis 高可用要求**：由于"Redis 故障 = 全员 403"，IAM 对 Redis 的可用性是硬依赖。生产环境必须：
- Redis 以 **Sentinel 或 Cluster 模式**部署，禁止单实例
- 单节点重启 / 故障不得导致所有请求 403
- 升级 / 重启必须走滚动替换，保证至少一个节点在线
- 应用层 Redis 客户端启用重试与断路器（短暂抖动自动恢复，超阈值才 fail fast）

**禁止**：
- 禁止在 JWT payload 里缓存 permissions / dataScopes（决议 #6），否则撤权 SLA 退化为 token 有效期（30d）
- 禁止在 `request.user` 外再开第二路权限数据源（避免不一致）

**性能预算**：每请求 +1 次 Redis GET（命中率应 > 99%），p50 < 1ms，p99 < 5ms；可接受。

#### 5.3.6 读操作流程（Interceptor 自动注入 + 404 语义）

```
用户请求 GET /purchase-orders/:id 或 /purchase-orders?filter=...
  ↓
PermissionsGuard: 有 purchase_order:read 权限码？ → ✅ 放行
  ↓
DataScopeInterceptor:
  1. 读取 @DataScope 装饰器元数据（resource + fieldMapping）
  2. 从 user.dataScopes 中匹配 resource='purchase_order' 或 '*'
  3. 按 §5.3.3 规则合并取最宽 scope
  4. 根据 scopeType + 字段映射生成 Prisma WHERE 条件
  5. 注入到 request.dataScopeFilter
  ↓
Controller → Service
  ↓
Service 从 request 获取 dataScopeFilter 并合并到查询：
  prisma.purchaseOrder.findMany({
    where: { ...businessFilters, ...dataScopeFilter }
  })
  prisma.purchaseOrder.findUnique({
    where: { id, ...dataScopeFilter }   // 单条查询同样合并
  })
```

**未授权返回 404（"不可见即不存在"）**：

若用户请求的单条记录 id 不在其 DataScope 范围内，`findUnique` 带 dataScopeFilter 会返回 null，Service 抛 `NotFoundException` → HTTP 404。

| 场景 | 返回码 |
|---|---|
| 记录不存在（id 错误/已删除） | 404 |
| 记录存在但用户 DataScope 不覆盖 | **404**（与"不存在"不可区分） |

**理由**：对齐 GitHub / GitLab 等主流实践，不告诉用户"这个 id 存在但你看不到"，减少存在性侧信道泄露。

**决策记录：为什么不统一返回 403**

OWASP Authorization Cheat Sheet 一派建议无论什么情况都返回 403（或 401），以避免"404 vs 403"的语义差成为侧信道。本规则选择 GitHub 风格（读 404 / 写 403），理由有三：

1. **读路径最小化存在性泄露**：用户不该知道 id 存在与否。统一 403 反而会告诉用户"这个 id 存在（但你没权限）"，比 404 泄露更多。
2. **UX 符合直觉**：读不到就是"不存在"，用户不用纠结"是 id 错了还是没权限"。
3. **写路径需要明确反馈**：写是用户主动行为，403 比 404 更清晰；让用户知道"这条确实存在但你没权限改"，避免重复提交。

代价：规则更复杂（两种返回码），但隐私收益 > 复杂度成本。

#### 5.3.7 写操作流程（Service 手动 assertAccess，返回码约定）

```
用户请求 PUT/PATCH/DELETE /purchase-orders/:id
  ↓
PermissionsGuard: 有 purchase_order:update 权限码？ → ✅ 放行
  ↓
Controller → Service
  ↓
Service:
  1. 查出记录（不带 dataScopeFilter）：
     record = prisma.purchaseOrder.findUnique({ where: { id } })
  2. 记录不存在 → 抛 NotFoundException（HTTP 404）
  3. 记录存在，校验归属：
     await dataScopeService.assertAccess(user, 'purchase_order', record)
     → 若 scope 不覆盖，抛 ForbiddenException（HTTP 403）
  4. 通过后执行写操作
```

**返回码约定**：

| 写操作场景 | 返回码 | 语义 |
|---|---|---|
| 记录不存在 | **404** | 无 |
| 记录存在但 DataScope 不覆盖 | **403** | "无权访问"，用户主动行为需明确反馈 |
| 权限码不足（Layer 3 拒绝） | 403 | PermissionsGuard 抛出 |

**为什么写操作查询不带 dataScopeFilter**（跟读不一样）：写操作需要"看到记录存在与否"才能做 404 vs 403 的区分；若带 dataScopeFilter，404 和 403 无法区分，会掩盖数据归属错误——而写操作是用户主动请求，明确的 403 比模糊的 404 更能反馈问题。

**禁止**：写操作不得跳过 `assertAccess`；见 §8 禁止事项 #10。

#### 5.3.8 DataScopeService 核心实现

```typescript
// backend/src/common/services/data-scope.service.ts

@Injectable()
export class DataScopeService {
  constructor(
    private orgContextService: OrganizationContextService,
  ) {}

  /**
   * 解析用户对指定资源的数据权限，返回 Prisma WHERE 条件
   * 从 user.dataScopes 读取（validateUser 时已加载），不查库
   */
  async resolve(
    user: RequestUser,
    resource: string,
    fieldMapping?: DataScopeFieldMapping,
  ): Promise<Record<string, any>> {
    if (this.isAdmin(user)) return {};

    // 从 user.dataScopes 中匹配（resource 精确匹配 > '*' 通配）
    const scopeType = this.matchScope(user.dataScopes, resource);

    return this.buildFilter(scopeType, user, fieldMapping);
  }

  /**
   * 校验单条记录是否在用户数据范围内（写操作用）
   */
  async assertAccess(
    user: RequestUser,
    resource: string,
    record: Record<string, any>,
    fieldMapping?: DataScopeFieldMapping,
  ): Promise<void> {
    if (this.isAdmin(user)) return;

    const filter = await this.resolve(user, resource, fieldMapping);
    if (!this.matchesFilter(record, filter)) {
      throw new ForbiddenException('No access to this record');
    }
  }

  private async buildFilter(
    scopeType: DataScopeType,
    user: RequestUser,
    fields?: DataScopeFieldMapping,
  ): Promise<Record<string, any>> {
    const f = { ...DEFAULT_FIELD_MAPPING, ...fields };

    switch (scopeType) {
      case DataScopeType.SELF:
        return { [f.userId]: user.userId };

      case DataScopeType.DEPARTMENT:
        const deptIds = await this.orgContextService.getUserDepartmentIds(user.userId);
        return { [f.departmentId]: { in: deptIds } };

      case DataScopeType.DEPARTMENT_TREE:
        const treeDeptIds = await this.orgContextService.getDepartmentTreeIds(user.userId);
        return { [f.departmentId]: { in: treeDeptIds } };

      case DataScopeType.ORGANIZATION:
        const orgIds = await this.orgContextService.getUserOrganizationIds(user.userId);
        return { [f.organizationId]: { in: orgIds } };

      case DataScopeType.REGION:
        return { [f.regionId]: user.currentRegion };

      case DataScopeType.ALL:
        return {};
    }
  }

  /**
   * 无 DataScope 配置时默认 SELF（安全侧）
   */
  private matchScope(dataScopes: UserDataScope[], resource: string): DataScopeType {
    const exact = dataScopes.find(s => s.resource === resource);
    if (exact) return exact.scopeType;
    const wildcard = dataScopes.find(s => s.resource === '*');
    if (wildcard) return wildcard.scopeType;
    return DataScopeType.SELF; // 安全默认值
  }
}

const DEFAULT_FIELD_MAPPING: DataScopeFieldMapping = {
  userId: 'createdById',
  departmentId: 'departmentId',
  organizationId: 'organizationId',
  regionId: 'regionId',
};
```

#### 5.3.9 DataScopeInterceptor（读操作自动注入）

```typescript
// backend/src/common/interceptors/data-scope.interceptor.ts

@Injectable()
export class DataScopeInterceptor implements NestInterceptor {
  constructor(
    private reflector: Reflector,
    private dataScopeService: DataScopeService,
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler) {
    const dataScopeMeta = this.reflector.get('dataScope', context.getHandler());
    if (!dataScopeMeta) return next.handle();

    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const { resource, fields } = typeof dataScopeMeta === 'string'
      ? { resource: dataScopeMeta, fields: undefined }
      : dataScopeMeta;

    request.dataScopeFilter = await this.dataScopeService.resolve(user, resource, fields);

    return next.handle();
  }
}
```

#### 5.3.10 OrganizationContextService（共享底层）

Guard 和 DataScopeService 共享此服务查询部门/组织关系，避免代码重复：

```typescript
// backend/src/common/services/organization-context.service.ts

const MAX_DEPT_TREE_DEPTH = 10;

@Injectable()
export class OrganizationContextService {
  constructor(private prisma: PrismaService) {}

  async getUserDepartmentIds(userId: string): Promise<string[]> { ... }

  /**
   * 递归获取用户所在部门及所有下属部门
   * maxDepth=10 防止环导致死循环
   */
  async getDepartmentTreeIds(userId: string): Promise<string[]> {
    const userDeptIds = await this.getUserDepartmentIds(userId);
    const allDeptIds = new Set(userDeptIds);

    const getChildren = async (parentIds: string[], depth: number) => {
      if (depth >= MAX_DEPT_TREE_DEPTH || parentIds.length === 0) return;
      const children = await this.prisma.department.findMany({
        where: { parentId: { in: parentIds }, deletedAt: null },
        select: { id: true },
      });
      const childIds = children.map(c => c.id).filter(id => !allDeptIds.has(id));
      childIds.forEach(id => allDeptIds.add(id));
      await getChildren(childIds, depth + 1);
    };

    await getChildren(userDeptIds, 0);
    return Array.from(allDeptIds);
  }

  async getUserOrganizationIds(userId: string): Promise<string[]> { ... }
}
```

#### 5.3.11 Controller 使用方式

```typescript
// 读操作：Interceptor 自动注入 dataScopeFilter
@Get()
@RequirePermissions('purchase_order:read')
@DataScope('purchase_order')
async findAll(@Req() req, @Query() filters) {
  return this.service.findAll(filters, req.dataScopeFilter);
}

// 写操作：Service 手动 assertAccess
@Put(':id')
@RequirePermissions('purchase_order:update')
async update(@CurrentUser() user, @Param('id') id, @Body() dto) {
  return this.service.update(user, id, dto);
  // Service 内部调 dataScopeService.assertAccess(user, 'purchase_order', record)
}
```

#### 5.3.12 测试策略

| 测试类型 | 覆盖内容 |
|---------|---------|
| **快照对比** | 迁移前后，Employee/DepartmentManager/HrManager 调每个列表 API，返回记录 ID 集合必须一致 |
| **DataScope 专项** | 每种 scopeType × 角色组合：正确过滤、assertAccess 拒绝越权、多角色取最宽 |
| **递归边界** | 正常 3 层树、空部门、单节点、10 层深树、有环数据（maxDepth 保护） |
| **无配置安全** | 角色无 DataScope 配置时默认 SELF（不泄露数据） |
| **静态契约** | `update/delete` 未配 `assertAccess` 或 `@SkipAssertAccess` 的 Service 在 CI 阻断 |
| **全局表 UX** | ORGANIZATION scope 用户读全局记录 200、写全局记录返回 403 且错误码 `SYSTEM_RECORD_READ_ONLY` |

#### 5.3.13 非用户主体：异步任务 / 定时任务 / Webhook 的身份语义

规则层的 DataScope 合并、权限检查都假设"请求-用户"模型。对非人类主体（定时任务、队列消费者、webhook 处理器）必须显式规定身份语义，否则会产生 Confused Deputy 类漏洞（用系统身份做用户操作）。

**规则**：

1. **异步任务必须显式携带 actor**，二选一：
   - **User Principal**："代表用户 X 执行"，task payload 中必须带 `userId`；加载该用户的 Redis 缓存作为 scope 来源；一切 DataScope / assertAccess 按该用户走。
   - **System Principal**：标记 `{ principal: 'system', source: 'cron:${jobId}' | 'queue:${queueName}' | 'webhook:${provider}' }`；豁免 DataScope（等价于 `ALL`），但**必须记审计日志**，字段包含 `source / jobId / affectedRecords`。

2. **禁止隐式回退**：task 没带 actor 的情况下，不得隐式用 Administrator 身份执行；必须抛错 `MissingActorException` 并告警。

3. **禁止在异步任务里用请求时刻的 `currentOrg`**：请求发起时的 `currentOrg` 在几分钟后执行的任务里可能已经无意义；任务自己必须在 payload 里显式携带 `organizationId`。

4. **Webhook 处理器**：外部系统回调必须先走 `InternalServiceGuard` 验证身份，通过后进入 System Principal 模式或按约定转换为 User Principal（基于 payload 里的业务标识映射用户）。

**审计要求**：System Principal 的所有操作必须记录（谁触发、改了什么、为什么）；避免"系统操作"变成不可追溯的黑箱。

#### 5.3.14 委托（Delegation）：经理休假的权限代理

企业系统必备能力——经理休假时将审批/读写权限委托给代理人。当前规则层将其作为 DataScope 的一等公民。

**数据模型**：

```prisma
model UserDelegation {
  id             String   @id @default(uuid()) @db.Uuid
  fromUserId     String   @map("from_user_id") @db.Uuid   // 委托人
  toUserId       String   @map("to_user_id") @db.Uuid     // 代理人
  resource       String                                    // "*" = 全量；具体 resource 限定
  validFrom      DateTime @map("valid_from") @db.Timestamptz(3)
  validTo        DateTime @map("valid_to") @db.Timestamptz(3)
  reason         String                                    // 必填：原因（休假 / 出差 / 离职交接）
  createdById    String   @map("created_by_id") @db.Uuid
  organizationId String   @map("organization_id") @db.Uuid
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt      DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  @@index([toUserId, validFrom, validTo])
  @@index([fromUserId])
  @@index([organizationId])
  @@map("user_delegations")
  @@schema("platform_iam")
}
```

**合并规则**：

- 代理人 B 在 `validFrom ~ validTo` 窗口内执行请求时，加载委托人 A 的权限与 DataScope，作为**附加来源**并入 B 自己的权限（取并集）。
- 多个委托并存时，逐个加载、逐个并入。
- 代理人执行的操作在审计日志中必须标记 `actor: B, on_behalf_of: A, delegation_id: ...`。
- 委托窗口外、或已撤销的委托不参与合并（判断 `validTo > now`）。

**配置约束**：

- 创建委托需要委托人自己发起（`createdById = fromUserId`），或由 `system:admin` 代为配置（代配场景必审计）。
- 委托 scope 不能超过委托人自己的 scope（走 §5.3.3.1 同样的 ceiling 规则）。
- 撤销：DELETE `UserDelegation` 记录 + `DEL user:${toUserId}:auth` 强制缓存刷新。

**禁止**：链式委托（B 再把 A 委托的权限委托给 C）——复杂度爆炸且审计难，一律拒绝。

#### 5.3.15 定期访问复核（Access Review）

SOC2 / ISO27001 等合规框架要求对用户-角色绑定做定期复核——"这个人为什么还有这个权限"。本规则将此作为强制机制。

**数据模型扩展**：

```prisma
model UserRole {
  // ... 现有字段
  lastReviewedAt   DateTime? @map("last_reviewed_at") @db.Timestamptz(3)
  lastReviewedBy   String?   @map("last_reviewed_by") @db.Uuid
  reviewComment    String?   @map("review_comment")
}
```

**规则**：

- 每条 `UserRole` 分配必须在 90 天内被复核一次；超过 90 天未复核视为"待复核"。
- 待复核超过 30 天（即总共 120 天未复核）自动告警到模块 owner 和 HR。
- 复核动作：模块 owner 在 UI 上逐项确认"保留 / 撤销"，更新 `lastReviewedAt` 和 `lastReviewedBy`。
- 撤销动作触发 §5.3.5 的 Redis 缓存失效 + active JTIs invalidate。
- 复核结果必须记审计日志。

**实施范围**：高敏感模块（审批 / 财务 / HR / 用户管理 / 权限管理）必须纳入复核流程；一般业务模块可选（由模块 PRD 决定）。

#### 5.3.16 部门树 maxDepth=10 超限行为

规则层禁止"静默截断"——不透明的数据可见性漏洞极难排查。

**规则**：

- `OrganizationContextService.getDepartmentTreeIds()` 递归超过 `maxDepth = 10` 层时，**抛 `DepartmentTreeTooDeepException`**（HTTP 500），**不静默返回部分结果**。
- 异常必须上报告警通道（包含 `deptId`, `currentDepth`），运维介入排查是否存在部门树设计问题或循环引用。
- 若业务确实需要更深的部门树（企业并购后的跨级合并等），**禁止**简单调高 maxDepth，必须先评估：是否应该扁平化部门设计？是否应该拆分为多个组织？

**理由**：深部门树项目是数据可见性漏洞的高危场景；规则选择"显式失败 + 告警"而非"尽力而为"。

#### 5.3.17 未知 Resource 的观测

`@DataScope('abc_typo')` 拼写错误时，`user.dataScopes` 里找不到匹配，按安全兜底默认 SELF——但这会**静默**地把一个可能期望 ORGANIZATION 的端点降级到 SELF，用户反馈"看不到自己本该看到的数据"才被发现。

**规则**：

- `DataScopeInterceptor` 匹配不到 resource（精确匹配无 + `*` 兜底也无）时，发出 **WARN 日志** + **metric counter `data_scope.unknown_resource`**，不得静默。
- WARN 日志字段：`userId / endpoint / declaredResource / availableResources`。
- Metric 接入监控告警，阈值超过基线触发 runbook 检查。

**目的**：让"拼写错误默默降级"变成可观测事件，不再是静默 bug。

#### 5.3.18 未来考虑（非当前规则要求）

以下项当前规则**不**要求实施，但在后续可能引入。记录在此避免被再次"发现"重新讨论：

- **Postgres RLS（Row Level Security）作为第二道防线**：当前规则是应用层 DataScope 单层防护，假设 §0.3 #2"应用独占数据库访问"。若该假设被打破（BI 工具直连、新服务走 DB 直连等），可考虑给高敏感表加 RLS 作为 defense-in-depth。代价：迁移复杂化、Prisma 需要适配。
- **Policy as Code（OPA / Rego / Cerbos）**：当前权限规则散落在代码和配置里，未来可考虑统一到声明式 policy 文件，支持版本化和 diff review。当前规模不值得引入。

---

### 5.4 字段级权限

#### 5.4.1 设计

通过 `FieldPermission` 配置控制特定角色看不到的字段：

```prisma
model FieldPermission {
  id          String   @id @default(uuid())
  roleId      String   @map("role_id")
  resource    String   // "purchase_order"
  field       String   // "cost_price"
  access      FieldAccess  // HIDDEN / READONLY / DESENSITIZE
  
  role        Role     @relation(fields: [roleId], references: [id])
  
  @@unique([roleId, resource, field])
  @@map("field_permissions")
  @@schema("platform_iam")
}

enum FieldAccess {
  VISIBLE       // 可见可编辑（默认）
  READONLY      // 可见不可编辑
  HIDDEN        // 完全不可见
  DESENSITIZE   // 脱敏显示
}
```

#### 5.4.2 使用方式

```typescript
// Service 层查询后过滤字段
const result = await this.prisma.purchaseOrder.findMany({ ... });
return this.fieldPermissionService.filter(user, 'purchase_order', result);

// filter 方法：
// - HIDDEN: delete record.cost_price
// - DESENSITIZE: record.phone = maskPhone(record.phone)
// - READONLY: 前端根据 metadata 控制
```

#### 5.4.3 前端配合

API 响应中附带字段权限元数据：

```json
{
  "data": [...],
  "fieldPermissions": {
    "cost_price": "hidden",
    "supplier_phone": "desensitize"
  }
}
```

前端根据 `fieldPermissions` 控制字段显示/编辑状态。

### 5.5 脱敏规则

| 字段类型 | 脱敏方式 | 示例 |
|---------|---------|------|
| 手机号 | 中间 4 位遮挡 | 138****1234 |
| 邮箱 | @ 前遮挡 | c***@example.com |
| 身份证 | 中间 8 位遮挡 | 310***********1234 |
| 银行账号 | 仅显示后 4 位 | ************5678 |
| 金额 | 根据角色决定是否显示 | ****（HIDDEN）或正常显示 |

### 5.6 实施要求（规则层不分期）

**规则层不存在 V1/V2/V3/V4 分期**。所有规则即时生效，实施按优先级推进，见 `09-iam-implementation-todo.md`。

实施要求清单（规则层定义的每一项都必须实现，不存在"本期做 / 后续再做"的退路）：

**Layer 1（身份认证）**
- [ ] Access Token 30d + Refresh Token 30d（撤权依赖 Redis 黑名单 / active_jtis，不依赖自然过期）
- [ ] Redis Token 黑名单（JTI 做 key）
- [ ] `user:${userId}:active_jtis` 列表用于解雇场景主动失效
- [ ] 登录限流 30s ≤ 5 次

**Layer 2（组织上下文）**
- [ ] 多部门用户 DEPARTMENT / DEPARTMENT_TREE 取 active 部门并集
- [ ] 部门树递归 maxDepth=10 防环

**Layer 3（功能权限）**
- [ ] `PermissionsGuard` 全局注册
- [ ] 每个端点必有 `@RequirePermissions` 或 `@Public()`（Guard 对无装饰器端点拒绝）
- [ ] 废弃装饰器（`@Roles`、`@RequireOrganizationPermissions` 等）完全删除
- [ ] `Administrator` 角色名大小写统一（`code='Administrator'`）
- [ ] Administrator 权限跳过必记审计日志

**Layer 4（数据权限）**
- [ ] `DataScopeInterceptor` 全局注册，读操作自动注入 WHERE
- [ ] CUSTOM 类型运行时抛 `NotImplementedException`，seed 禁用
- [ ] 多角色合并按 resource 独立 + 仅当前组织（§5.3.3）
- [ ] 配置 scope ceiling 校验（§5.3.3.1）
- [ ] DataScope 配置变更审计（§5.3.3.2）
- [ ] 无配置默认 SELF，无 `@DataScope` 装饰器端点由 Interceptor 按 SELF 兜底
- [ ] 写操作 Service 层必 `assertAccess`，返回 404/403 语义区分（§5.3.7）
- [ ] 读未授权单条查询返回 404（§5.3.6）
- [ ] `ORGANIZATION` scope WHERE 含 `OR organizationId IS NULL` 支持全局表透明
- [ ] 写路径服务端强制覆盖 `organizationId` 为 `currentOrg`，忽略客户端字段
- [ ] 创建全局记录（`organizationId = null`）的专用 `POST /system/...` 接口，`system:admin` 保护
- [ ] 静态契约检查：CI / pre-commit 扫描 `update/delete` 无 `assertAccess` → 阻断（§7.1）
- [ ] `@SkipAssertAccess('理由')` 装饰器 + 理由必填
- [ ] 异步任务身份语义：task payload 带 `{ userId }` 或 `{ principal: 'system', source }`，无 actor 抛异常（§5.3.13）
- [ ] 委托机制：`UserDelegation` 模型 + 合并 + 审计（§5.3.14）
- [ ] 定期 Access Review：`UserRole.lastReviewedAt`，90 天超期告警（§5.3.15）
- [ ] 部门树超过 maxDepth 抛 `DepartmentTreeTooDeepException` + 告警（§5.3.16）
- [ ] Resource 未知时发 WARN 日志 + metric，不静默（§5.3.17）

**性能 & 可观测**
- [ ] 权限数据 Redis 缓存 TTL 5min（≤5min 撤权 SLA）
- [ ] Redis 故障时拒绝访问，不降级跳过
- [ ] **Redis 高可用部署**（Sentinel 或 Cluster，禁止单实例）
- [ ] 部门树 Redis 缓存（`dept_tree:${deptId}`，TTL 1h，部门 CRUD 时失效）
- [ ] 权限拒绝异常带结构化字段（userId / endpoint / 缺哪条权限）
- [ ] DataScope 字段 `@@index`（见 04-database-architecture.md 标准字段）
- [ ] Metric：`data_scope.unknown_resource` counter 接入监控告警

**字段级权限 & 脱敏**
- [ ] `FieldPermission` 模型骨架（按 §5.4）
- [ ] 脱敏规则（按 §5.5）
- [ ] 具体字段配置随业务需求驱动，配置项本身不强制——但基础设施必须存在

> **实施进度**不记录在本文件，本文件只声明规则。当前已完成 / 未完成项见 `docs/standards/09-iam-implementation-todo.md`。

### 5.7 关键文件

```
backend/src/common/services/
├── data-scope.service.ts        # DataScope 解析和 WHERE 条件生成
├── organization-context.service.ts  # 部门树、组织层级共享
└── field-permission.service.ts  # 字段过滤和脱敏

backend/src/common/decorators/
└── data-scope.decorator.ts      # @DataScope 装饰器

backend/src/common/interceptors/
└── data-scope.interceptor.ts    # 自动注入 dataScopeFilter（全局注册）

backend/prisma/schema/platform_iam.prisma  # DataScope, RoleDataScope, FieldPermission
backend/prisma/seeds/data-scopes.seed.ts   # 内置 DataScope 种子数据
```

---

## 六、前端权限体系

### 6.1 权限加载

```
登录 → token 存入 localStorage + Zustand
  → AuthProvider 调用 /users/me
  → 从 user.roles[].role.permissions 提取权限码
  → 存入 Set<string>（O(1) 查找）
  → Administrator 角色自动获得通配符 *
```

### 6.2 三级前端保护

| 级别 | 组件/机制 | 说明 |
|------|----------|------|
| **菜单级** | `useFilteredNavigation()` | 过滤侧边栏菜单项，无权限不显示 |
| **页面级** | `<PermissionGuard>` | 包裹页面组件，无权限显示 403 |
| **元素级** | `<Can>` | 包裹按钮/操作，无权限不渲染 |

### 6.3 使用规范

```tsx
// 页面级保护（每个需要权限的页面必须有）
export default function PurchaseOrderPage() {
  return (
    <PermissionGuard permissions={['purchase_order:read']}>
      <PageContent />
    </PermissionGuard>
  );
}

// 元素级保护（操作按钮必须有）
<Can permission="purchase_order:create">
  <Button onClick={handleCreate}>新建采购单</Button>
</Can>

<Can permission="purchase_order:approve">
  <Button onClick={handleApprove}>审批</Button>
</Can>
```

### 6.4 菜单权限映射

在 `frontend/src/config/navigation.ts` 中配置：

```typescript
{
  name: '采购管理',
  path: '/purchase-orders',
  permissions: ['purchase_order:read', 'purchase_order:list'],
  // 用户有任一权限即可看到菜单
}
```

### 6.5 关键文件

```
frontend/src/
├── contexts/AuthContext.tsx              # 权限加载 + hasPermission()
├── components/common/PermissionGuard.tsx # 页面级 + <Can> 组件
├── hooks/useFilteredNavigation.ts       # 菜单过滤
├── hooks/useAuthGuard.ts               # 认证守卫
├── config/navigation.ts                 # 菜单-权限映射
└── lib/auth.ts                          # Zustand 认证状态
```

---

## 七、新模块接入 Checklist

每个新模块接入权限系统时，按以下步骤执行：

### 7.1 后端

- [ ] 在 `permissions.seed.ts` 添加模块权限码（resource:action 格式）
- [ ] 在 `roles.seed.ts` 中为相关角色分配权限
- [ ] 在 `data-scopes.seed.ts` 中为相关角色配置数据范围（RoleDataScope）
- [ ] 控制器每个端点添加 `@RequirePermissions` 或 `@Public()`
- [ ] 需要数据权限的端点添加 `@DataScope('resource_name')` 或带字段映射的版本
- [ ] Service 层列表查询调用 `dataScopeService.resolve(user, resource)` 获取数据范围过滤条件
- [ ] Service 层写操作（PUT/PATCH/DELETE）调用 `dataScopeService.assertAccess(user, resource, record)` 校验数据归属 — 详见下方 `写操作 assertAccess 铺设规则`
- [ ] 集成测试覆盖 "A 用户用 B 用户的 id 改/删数据 → 期望 403"（高敏感模块必测）
- [ ] 运行 `prisma db seed` 更新权限数据

#### 写操作 assertAccess 铺设规则

写操作（PUT / PATCH / DELETE）读操作 Interceptor 不能覆盖，必须在 Service 层手动调 `dataScopeService.assertAccess(user, resource, record)`，否则会产生 IDOR 漏洞（攻击者用任意 id 改/删别人的数据）。

分三档执行：

| 模块档次 | 要求 | 典型模块 |
|---|---|---|
| **高敏感** | **强制加** + 集成测试覆盖 "跨用户改/删 → 403" | 审批、财务、HR、用户/角色/权限、部门/岗位、表单发布 |
| **一般业务** | **默认加**；可在模块 PRD 申请豁免并记录理由 | 会议、考勤、备件、知识库、AI 助手 |
| **天然隔离** | 可不加，必须在模块 PRD 记录原因 | 个人偏好、通知订阅等 WHERE 天然带 `userId` 的资源 |

**双重兜底（规则要求）**：

1. **静态契约检查**：CI / pre-commit 扫描 Service 层，若函数体内出现 `prisma.xxx.update(` / `prisma.xxx.delete(` 且同函数（或调用链可达）**未出现** `assertAccess(` / `@SkipAssertAccess`，视为违规阻断。  
   - 允许 `@SkipAssertAccess('理由')` 装饰器显式豁免（理由必填）
   - 已知误报场景（"更新当前用户自己的记录"）通过 `@SkipAssertAccess` 标记，不靠放松检查规避
2. **集成测试强制**：每个高敏感模块的 update / delete 端点，必须有 "A 用户用 B 的 id 改 → 403" 测试；模块 README 列出测试清单。
3. **PR Review 作为第三道保险**：审 PR 时若看到 `update/delete` 未配 `assertAccess` 或 `@SkipAssertAccess`，在 PR 评论质疑；静态检查有漏网时靠人工补。

三重保护缺一不可——IDOR 是零容忍漏洞，不接受"先放着后面补"。

### 7.2 前端

- [ ] 在 `navigation.ts` 添加菜单项及其 permissions 配置
- [ ] 页面组件包裹 `<PermissionGuard>`
- [ ] 操作按钮包裹 `<Can>`
- [ ] 确认无权限用户看不到菜单、访问页面显示 403、按钮不渲染

### 7.3 文档

- [ ] 在模块 PRD 的「角色权限矩阵」中列出权限分配
- [ ] 在模块 API 文档中标注每个端点所需权限码

---

## 八、禁止事项

1. **禁止 DISABLE_AUTH_GUARDS**：不得通过环境变量跳过权限检查
2. **禁止 @Roles**：已废弃，全部使用 @RequirePermissions
3. **禁止带 Scope 的装饰器**：@RequireOrganizationPermissions 等已废弃，数据范围由 DataScope 管理
4. **禁止冗余 @UseGuards**：Guard 已全局注册，不要在控制器上重复
5. **禁止无装饰器端点**：每个端点必须有 @RequirePermissions 或 @Public()
6. **禁止手动数据范围过滤**：不要在 Service 中手写 WHERE 做身份维度过滤，统一用 DataScopeService；业务维度 WHERE（时间/状态/金额）走 Service，见 §5.3.1.2
7. **禁止手动角色检查**：不要在代码中 `if (user.roles.includes(...))`，用装饰器
8. **禁止内存 Token 黑名单**：必须使用 Redis；Redis 不可用时拒绝访问，**不降级为跳过检查**
9. **禁止使用 CUSTOM 类型 DataScope**：由 §5.3.1.2 职责边界推出，永久禁用。运行时抛 `NotImplementedException`，seed 禁用；新"身份维度"走加枚举值，新"业务维度"走 Service WHERE
10. **禁止写操作跳过 assertAccess**：PUT/PATCH/DELETE 必须在 Service 层调用 `dataScopeService.assertAccess`，豁免模块需在 PRD 记录原因
11. **禁止 JWT payload 携带权限数据**：JWT 只含 `userId / exp / jti`；权限码和 DataScope 走 Redis 缓存。否则撤权 SLA 退化为 token 有效期
12. **禁止跨组织合并 DataScope**：`request.user.dataScopes` 按组织分层存储，请求时按 currentOrg 切片；跨组织角色身份不得相互污染
13. **禁止配置超过自己 scope 的 DataScope**：§5.3.3.1 配置 ceiling 硬约束；Administrator 和 `system:admin` 豁免
14. **禁止客户端直接设置 `organizationId`**：业务记录的 `organizationId` 由系统根据 `currentOrg` 填充；创建全局记录（`organizationId = null`）需要 `system:admin`
15. **禁止读操作单条查询不带 dataScopeFilter**：会导致用户看到 scope 外的数据；单条读取必须 `findUnique({ where: { id, ...dataScopeFilter } })`
16. **禁止在本文件维护实施进度**：实施进度只在 `09-iam-implementation-todo.md`，本文件只声明规则
17. **禁止异步任务无显式 actor**：定时任务 / 队列消费者 / webhook 处理器必须携带 `userId` 或 `system principal` 标识；无则抛 `MissingActorException`，**不得隐式用 Administrator 身份**（§5.3.13）
18. **禁止链式委托**：`UserDelegation` 只允许 A → B 一级委托，B 不得再把 A 委托的权限转委托给 C（§5.3.14）
19. **禁止部门树超限静默截断**：递归超过 `maxDepth = 10` 必须抛异常 + 告警，不得返回部分结果（§5.3.16）
20. **禁止单实例 Redis 部署**：生产环境 Redis 必须 Sentinel 或 Cluster；单实例等于让整个 IAM 成为 SPOF（§5.3.5）
21. **禁止未知 resource 静默降级**：Interceptor 匹配不到 resource 时必须 WARN 日志 + metric，不得不吭声兜底到 SELF（§5.3.17）
22. **禁止跳过静态契约检查**：`update/delete` 必须配 `assertAccess` 或 `@SkipAssertAccess('理由')`；CI 阻断不可绕过（§7.1）

---

## 九、实施进度

规则层不承认"当前状态 vs 目标状态"的划分——规则就是规则，全部必须达到。

当前代码实现的进度追踪在 `docs/standards/09-iam-implementation-todo.md`，按优先级组织（P0 / P1 / P2）。此 TODO 文件只用于跟踪实施进度，**不用于偏离规则**；所有 TODO 项完成后整个 IAM 系统才算符合本规则。

本文件不再维护实施进度；任何"当前是 X"的表述应指向 TODO 文件而非改本文件。
