# 安全规范（IAM 四层模型）

> 适用入口：`backend-main`
> 完整规则：[`docs/standards/09-iam-security.md`](../../../docs/standards/09-iam-security.md)（17 决策 / 22 禁止事项）

## 概览：四层安全模型（PR #138 引入）

```
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Authentication （JwtStrategy + JTI 黑名单 + 限流） │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Organization Context （X-Organization-Id 切片）    │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: RBAC （@RequirePermissions + PermissionsGuard）    │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: DataScope （@DataScope + assertAccess + 行级过滤） │
└─────────────────────────────────────────────────────────────┘
```

业务模块通常只关心 Layer 3 + Layer 4。Layer 1 / Layer 2 由平台基础设施统一处理。

---

## Layer 3：权限码（resource:action）

### 写代码

```ts
// Controller
@Controller('orders')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class OrdersController {
  @Post()
  @RequirePermissions('order:create')   // 单个权限
  create(@Body() dto: CreateOrderDto) {}

  @Get()
  @RequirePermissions('order:read')
  list() {}
}
```

**禁用**（已删除导出，导入会编译报错）：
- `@RequireOrganizationPermissions` / `@RequireGlobalPermissions`
- `@Roles`（角色判定改走 `isAdministrator(user)` util）

### 写 seed（必做）

1. 在 `backend/prisma/seeds/permissions.seed.ts` 加权限：
   ```ts
   { resource: 'order', action: 'create', description: '...', module: '...' },
   { resource: 'order', action: 'read', ... },
   ```

2. 在 `backend/src/common/constants/data-scope-resources.ts` 注册 resource（如已有则跳过）：
   - 命名规范：snake_case + 单数名词（`order` 而非 `orders`）
   - 历史命名 `parts / org / site-attendance / robot-manager` 已列入 `LEGACY_DATA_SCOPE_RESOURCES`，新模块不要重蹈

3. 在 `backend/prisma/seeds/data-scopes.seed.ts` 把权限绑给目标角色

### 验证

- `RequirePermissions` 缺失 → guard 直接 `return true`，**接口裸奔** —— 一定要装饰
- 权限码错字 → seed 跑通但 controller 永远 403。pre-commit 静态契约会扫
- `pre-commit` 自动跑 `data-scope-resources-check.ts` 校验 resource 是否合规

---

## Layer 4：数据权限（行级过滤 + IDOR 防护）

### A. 列表过滤（@DataScope）

```ts
// service
@DataScope({ resource: 'order' })   // 让 DataScopeInterceptor 注入 where 子句
async findAll(query: QueryDto) {
  return this.prisma.order.findMany({ where: query });
}
```

DataScopeInterceptor 全局注册，根据当前用户的 RoleDataScope 注入 `where: { OR: [...] }`：
- 系统级 wildcard `*` → 全量
- ORGANIZATION → 当前/可见组织 + 对 `organizationId IS NULL` 透明放行
- DEPARTMENT_TREE → 用户部门子树
- SELF → `createdById = userId`
- CUSTOM → **永久 NotImplementedException**（规则 §5.3.4，禁止扩展）

**不要**在 service 自己再写 `where: { organizationId: user.organizationId }` —— 双重过滤导致空结果。

### B. 单条按 ID 取（assertAccess）

```ts
async update(id: string, dto: UpdateDto, user: AuthUser) {
  const order = await this.prisma.order.findUnique({ where: { id } });
  if (!order) throw new NotFoundException();

  // ⚠️ IDOR 防护：拿到对象后必须校验当前用户是否有权访问
  await this.dataScope.assertAccess(user, 'order', order);

  return this.prisma.order.update({ where: { id }, data: dto });
}
```

如果方法明确不需要校验（如读 `/me` 只取自己资源），**必须**显式标记：
```ts
@SkipAssertAccess('readMe 只查自己的 order，业务层已保证 createdById = userId')
async readMe(user: AuthUser) { ... }
```

`testing/scripts/assert-access-check.ts` 在 pre-commit 跑：service 方法接收 `id: string` 参数但既没调用 `assertAccess` 也没标 `@SkipAssertAccess` 的 → 阻断提交。

### C. 标准字段（新业务表必须）

新建 Prisma model 必含：
- `id`（uuid）
- `createdAt` / `updatedAt`
- `createdById`（uuid，关联 users）
- `organizationId`（可空，DataScope 决议依赖）

按需追加：`departmentId` / `regionId`。命名规则：禁用 `creatorId / ownerId / orgId` 同义词。

详见 `docs/standards/04-database-architecture.md` 「标准字段」章节。

---

## 治理：审计日志

### 业务级审计（高频写操作）

```ts
// 走全局 @Auditable() 装饰器（最简单）
@Patch(':id/approve')
@Auditable()
@RequirePermissions('order:approve')
async approve(@Param('id') id: string, @CurrentUser() user) { ... }
```

### IAM 级审计（权限/角色/委托等元数据变更）

```ts
constructor(private readonly audit: IamAuditService) {}

async grantRole(userId: string, roleId: string, actor: string) {
  const before = await this.prisma.userRole.findUnique({ ... });
  const after = await this.prisma.userRole.create({ ... });
  await this.audit.record({
    actor, action: 'CREATE', resource: 'UserRole',
    targetId: after.id, before, after,
  });
}
```

写失败默认 fire-and-forget（不阻塞主请求），interceptor 层会 catch + log。

---

## 字段级权限（敏感字段脱敏）

新模块如包含敏感字段（手机号 / 身份证号 / 工资 / 地址）：

```ts
constructor(private readonly fieldPerm: FieldPermissionService) {}

async findOne(id: string, user: AuthUser) {
  const order = await this.prisma.order.findUnique({ where: { id } });
  return this.fieldPerm.maskFields(user, order, 'order');
}
```

字段权限规则在 `field_permissions` 表配置（FullText / Masked / Hidden）。

---

## 平台级能力（业务模块通常不直接调）

| 能力 | 类 | 何时遇到 |
|---|---|---|
| 权限委托 | `PermissionDelegationService` | 用户休假场景平台已统一处理；业务模块不直接调 |
| Access Review | `AccessReviewService` | 90 天复核 SLA，IAM 治理后台已暴露端点 |
| 紧急豁免 | `EmergencyBypassService` | 运维事故响应专用，无业务暴露 |
| 系统身份 | `SystemPrincipalService.getSystemActor()` | **业务模块需要**：cron/queue/webhook 触发的写操作不该挂在某个真实 user 头上，用系统 actor |

---

## 输入/输出

- 所有入参用 DTO + class-validator 装饰器（**`whitelist:true` 全局生效**，未装饰字段会被剥光，是常见 bug 源）
- 响应中脱敏敏感字段（密码哈希 / token / 内部 ID）
- 路径参数 UUID 用 `ParseUUIDPipe`

---

## 风险控制

- 登录接口走 `ThrottlerGuard`（默认 30s/5 次，由 `AUTH_THROTTLER_*` env 配置）
- 文件上传限制类型 + 大小
- 长查询（>100 行）必须分页

---

## 部署 / 回归注意

- PR 改 IAM 数据形态时，部署前必须 flush Redis `user:*:auth` cache
- Refresh token 设计变更时，所有用户被强制重登
- AuthCache TTL = 5 分钟（撤权 SLA）

---

## 审计 / IAM / Seed 实战陷阱（2026-05 沉淀，7 条 learning）

> 项目踩过的、有真实事故记录的安全相关陷阱。本节集中沉淀，写新模块前先翻。

### 1. ⚠️ IAM role mutation 必须 `authCache.invalidate(userId)`（P0 安全）

**事故记录**：2026-05-04 FFAI UAT 实测——admin 给 chentao.jia 加 `MeetingManager` 角色后，**7 次重试创建会议都 403**。Redis cache 命中陈旧权限，systemRoles.roles 仍是登录时的快照。

**设计意图**：`AuthCacheService` 在 Redis 缓存 user 权限 5 分钟（撤权 SLA）。设计假定：**任何角色 / 权限变更都主动调 `authCache.invalidate(userId)`**——cache TTL 仅是兜底。

**已修**：`UsersService` 7 处 role mutation 全部调用 `authCache.invalidate(userId)`（grep 验证：`backend/src/modules/organization/users/users.service.ts` 行 671 / 985 / 1103 / 1154 / 1209 / 1244 / 1273）。

**规则**（写新 service / 新模块涉及角色或权限变更时）：

| 方法语义 | 安全等级 | 必调 invalidate |
|---|---|---|
| `terminateUser`（离职 / 禁用账户） | **P0** —— 不调 = 离职用户 5min 内仍可操作 | ✅ |
| `assignRoles` / `removeRole` / `assignRegionRoles` / `removeRegionRole`（全替换 / 撤角色） | **P0** —— 撤掉的角色 5min 仍生效 | ✅ |
| `addRoles` / `addRegionRole`（增量加） | P1 —— 新权限滞后 5min 生效，用户体验问题 + 违反 SLA 承诺 | ✅ |
| 任何修改 `user_role_rel` / `user_permission` / `dataScope` 关系表的 service | **P0/P1** | ✅ |

**写新 mutation 时的硬规则**：

```ts
async someRoleMutation(userId: string, ...) {
  // 1. DB 写操作
  await this.prisma.userRoleRel.update(...);
  // 2. 必须紧跟 cache invalidate
  await this.authCache.invalidate(userId);
}
```

**检测命令**（catching missing invalidate）：

```bash
# 找所有改 user_role_rel / userPermission / dataScope 但未调 authCache 的 service
grep -rE "(userRoleRel|userPermission)\.(create|update|delete|upsert)" backend/src/modules/ \
  --include='*.service.ts' -l | xargs -I {} sh -c 'grep -L "authCache.invalidate" {}'
```

参考 learning: [`.learnings/2026-05-04-iam-role-mutation-cache-invalidate.md`](../../../.learnings/2026-05-04-iam-role-mutation-cache-invalidate.md)

### 2. 审计装饰器接入度：机制证据 > 端点遍历

**反模式**：要验证"284 个 `@Auditable` endpoint 是否真接入审计"，第一反应**调 280+ 次接口 + 查 audit_log 增量**。三个坑：

1. mutation 接口缺 body / 缺路径参数 → 4xx，验证管道拦截 → 跑出来全是 FAILED 路径，跟 SUCCESS 链路不等价
2. **副作用风险**：随机调 POST/PUT/DELETE 创建脏数据 / 触发通知 / 误删资源
3. GET 类装饰器可能为 0 —— 合规审计普遍只审 mutation，"安全只调 GET"方案直接跑空

**正确做法**：装饰器满足以下三个条件时，**机制层单点测试**可传递性证明全量接入：

1. 装饰器只是元数据写入（`SetMetadata`），所有调用点元数据等价
2. 拦截器 / Guard 全局注册（`APP_INTERCEPTOR` / `APP_GUARD`），对所有请求一处实现
3. 拦截器用 `Reflector.getAllAndOverride(KEY, [handler, controller])` 读元数据

满足三点 → 任意 1 个 endpoint 的端到端集成测试通过 = 推及全部装饰点。

剩余工作分两类：

- **静态层**：扫装饰器声明清单（endpoint / module / verb / 是否敏感 / 财务）
- **历史证据层**：查 `audit_log` 表实际产生过日志的 controller 名分布，识别"声明在位但流量未触达"的灰区

**可复用脚本**：`testing/scripts/audit-coverage-scan.ts` —— 静态扫 + audit_log 分布查询，无副作用，重跑零成本。

参考 learning: [`.learnings/2026-05-08-decorator-coverage-via-mechanism-testing.md`](../../../.learnings/2026-05-08-decorator-coverage-via-mechanism-testing.md)

### 3. 装饰器接入度调研入口必须是 `controller` 全扫，不是 PRD 抽象业务域

**反模式**：第一次 Explore agent 按 PRD 列的 7 个 schema 名（`platform_iam` / `corp_approval` / `mfg_inventory` ...）查模块，得 **92.8%（91/97）** 接入率，结论"基本合规"。按 `backend/src/modules/` 目录**全量重扫**，真实数字 **57.8%（271/469）**——**差 35 个百分点**。

**根因**：

1. PRD 抽象的"业务域名" ≠ 代码目录名（`mfg_inventory` 实际是 `modules/parts/`，被误判"无模块"）
2. agent 找不到同名目录就报"无模块"，跳过实际承载该业务的模块
3. PRD 表面"在范围里的都接入了"，但实际有 5 个业务模块的 controller **0% 接入**

**规则**（覆盖率调研标准动作）：

1. **先列代码事实**：`find backend/src -name "*.controller.ts"` 把所有 controller 全列
2. **以 controller 路径为单位统计**：每个文件 `@Post/@Put/@Patch/@Delete` 数 vs `@Auditable/@Financial/@Sensitive` 数
3. **再回头映射业务域**：把代码模块反向归到 PRD 业务域，缺失的业务模块单独列
4. **明确豁免范围**：审计模块自身（`core/observability/`）的查询 / 导出 / 告警 endpoint 是审计基础设施，**不审计自己**——必须写在表里，不写会被当成缺口

**禁止**：以 PRD 抽象名为入口做调研。

参考 learning: [`.learnings/2026-05-08-coverage-survey-by-schema-name-misleads.md`](../../../.learnings/2026-05-08-coverage-survey-by-schema-name-misleads.md)

### 4. `audit_log.entity_type` 双风格历史债

**现状**：`platform_audit.audit_log.entity_type` 列同时存在两种风格：

- **PascalCase（Prisma 模型名）**：`User` / `Document` / `Invoice` —— 手动 `auditService.log()` 调用 / seed
- **URL-segment（小写复数）**：`users` / `auth` / `tickets` —— `AuditLogInterceptor.extractEntityType()` 从 HTTP 路径自动提取

**影响**：按 `entityType` 精确匹配查询会**漏数据**——输入 `User` 找不到 16 条 `users`。

**当前缓解**：`audit.service.ts::getEntityHistory` 用 `{ equals, mode: 'insensitive' }` 解决大小写差异，**但仍不解决单复数**。

**规则**（写新 controller 时）：

- 优先用 `@Auditable({ entityType: 'User' })` **显式指定 PascalCase 模型名**，不依赖 URL 自动推断
- Code Review checklist 加一条：检查 `@Auditable` 是否含 `entityType` 参数

**长期 ETL**（不在当前 scope）：把历史 `users` 改写为 `User`。注意：审计表禁止 UPDATE，需走分区切换或追加修正记录。

参考 learning: [`.learnings/2026-05-08-audit-entity-type-dual-style.md`](../../../.learnings/2026-05-08-audit-entity-type-dual-style.md)

### 5. 审计装饰器覆盖缺口（写新模块的硬要求）

**当前缺口**（2026-05-07 扫描时）：

- **绩效（performance）**：9 个 controller，0 个 `@Auditable` —— **整个模块不留痕**
- **会议考勤（meeting-attendance）**：0 个装饰器
- **同步类内部任务**（dingtalk/sap 定时跑的部分）：0 个

**根因**：`AuditLogInterceptor` 走**白名单**模式——只有 `@Auditable()` 显式标记才记录（不是 bug，是设计选择，否则 GET 请求会爆炸）。

**规则**（写新模块时，新 controller / 新 mutation endpoint 必查）：

```bash
# 扫所有 mutation endpoint
grep -rE "@(Post|Put|Patch|Delete)" backend/src/modules/<module>/ | wc -l
# 扫所有 @Auditable
grep -rE "@Auditable" backend/src/modules/<module>/ | wc -l

# 差值 = 缺口数；如果 = 0 = 完全合规
```

**高敏感操作必标 `@Sensitive` 或 `@Financial`**（叠加在 `@Auditable` 上）：

| 操作类型 | 装饰器 | 保留年限 |
|---|---|---|
| 一般 mutation | `@Auditable()` | 项目默认（如 2 年）|
| 密码 / 权限 / 删除 / 角色变更 | `@Auditable() + @Sensitive()` | 5 年 |
| 支付 / 退款 / 财务调整 | `@Auditable() + @Financial()` | 7 年 + `isFinancial` 标记 |

参考 learning: [`.learnings/2026-05-07-audit-decorator-gaps.md`](../../../.learnings/2026-05-07-audit-decorator-gaps.md)

### 6. 声明式 seed（每次先清空再 create，不要靠 `upsert`）

**事故记录**：v1 seed 给 Sales 角色赋了 `robot-manager:update`，v2 移除后跑 seed，DB 仍有 5 条权限（含残留 update）。**3 轮文档 review 都比对 seed vs PRD "一致"，调 `/users/me` 才暴露**。

**反模式**：

```ts
// ❌ upsert 只保证"关系存在"，不保证"只有这些关系存在"
for (const key of r.permissionKeys) {
  await prisma.rolePermission.upsert({
    where: { roleId_permissionId: { roleId, permissionId } },
    create: { roleId, permissionId },
    update: {},
  });
}
// 老的、新 seed 已移除的关系**永远留在 DB**
```

**正确**：

```ts
// ✅ 先清空该角色在该命名空间下的所有关系，再按 permissionKeys 重建
await prisma.rolePermission.deleteMany({
  where: {
    roleId,
    permission: { resource: { startsWith: 'robot-manager:' } },
  },
});
for (const key of r.permissionKeys) {
  const permId = await prisma.permission.findUnique({ where: { code: key } });
  await prisma.rolePermission.create({ data: { roleId, permissionId: permId.id } });
}
```

**规则**：

- IAM seed（role / permission / rolePermission / dataScope）**必须声明式**（先清后 create）
- 业务种子（菜单 / 字典）若版本会演化也建议声明式
- **`upsert.update: {}` 是 anti-pattern**（不更新但本地变量已"以为更新了"，FK 漂移；新副作用对老数据从未生效——详见 [`database-standards.md`](../../database-main/references/database-standards.md) §"Prisma 实战陷阱" §3）

参考 learning: [`.learnings/2026-04-14-seed-upsert-drift.md`](../../../.learnings/2026-04-14-seed-upsert-drift.md)

### 7. Seed 链审计

**已踩坑**：

1. `performance-seed.ts` 与 `performance-basic-seed.ts` **完全重复**（79 行 / 79 行，仅注释不同），两者都注册 `if (require.main === module)`，**主入口 `prisma/seed.ts` 没引用任何一个**——僵尸冗余
2. `performance-demo-seed.sql` 注释写"前置：需先执行系统主种子（users/orgs/**departments**）"，但实际 seed 链**没有任何脚本插入 `corp_hr.departments`** —— 直接跑 fail

**规则**（添加新 seed 时）：

- 新 seed 文件**必须注册到 `prisma/seeds/index.ts`**，确保统一入口可发现
- 新 seed 的**前置依赖必须在 seed 链内**（不能注释说"需要 departments"但 departments 没人种）
- 删旧 seed 时同步从 `prisma/seeds/index.ts` 移除引用（不能留僵尸）
- code review 加一条：grep 新 seed 文件的 import / require 是否被 `prisma/seeds/index.ts` 拉起

参考 learning: [`.learnings/2026-05-07-seed-chain-gaps.md`](../../../.learnings/2026-05-07-seed-chain-gaps.md)

### 8. 新模块前置安全 checklist

写新 controller / service / seed 前自检：

- [ ] 所有 mutation endpoint 标 `@Auditable()`；敏感操作叠 `@Sensitive()` / 财务操作叠 `@Financial()`
- [ ] `@Auditable({ entityType: 'PascalCase' })` 显式 PascalCase 模型名，不靠 URL 推断
- [ ] 改 `user_role_rel` / `userPermission` / `dataScope` 关系的 service **紧跟** `authCache.invalidate(userId)`
- [ ] IAM seed 用**声明式**（先清后 create），禁止 `upsert.update: {}` 累积
- [ ] 新 seed 文件注册到 `prisma/seeds/index.ts`
- [ ] 新 seed 的前置依赖在 seed 链内（不能注释说"需要 X"但 X 没人种）
