feat(iam): 完整实现 iam 身份认证与权限服务

包含 jwt/jwks/audit/grpc、rbac、cache、redis/kafka 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:39 +08:00
parent 06e0f9139b
commit a35e759d64
32 changed files with 2589 additions and 778 deletions

View File

@@ -1,10 +1,14 @@
# 模块架构设计文档 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
> 状态:待 coord 交叉审查
> 模块IAM身份认证与权限
> 版本v1.0(最终态,对齐 coord-final-decisions I1-I8 + president §2.15/§2.16/§3.2/§5.5
> 日期2026-07-10
> 关联:
>
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)
> - [仲裁决策](../../../docs/architecture/coord-final-decisions.md)
> - [总裁裁决](../../../docs/architecture/president-final-rulings.md)
> - [iam 对接契约](../../../docs/architecture/issues/contracts/iam_contract.md)
---
@@ -15,65 +19,61 @@
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
Req[HTTP 请求<br/>带 x-user-id / x-user-roles 头]
REST[HTTP 请求<br/>带 x-user-id / x-user-roles / x-user-data-scope 头]
GRPC[gRPC 调用<br/>next_edu_cloud.iam.v1]
end
subgraph NestJS["iam 服务NestJS"]
subgraph NestJS["iam 服务NestJS Hybrid App"]
direction TB
MW[AuthMiddleware<br/>❌ 当前未注册P2 仍走 header 直读]
Guard[PermissionGuard<br/>APP_GUARD 全局守卫]
MW[AuthMiddleware<br/>已注册me/logout/viewports/<br/>permissions/roles/audit/children]
Guard[PermissionGuard<br/>APP_GUARD 全局守卫<br/>DB 驱动 + Redis 缓存]
Filter[GlobalErrorFilter<br/>全局异常过滤器]
subgraph Controllers["Controller 层"]
IamCtl[IamController<br/>/iam/register, login, refresh, me]
RbacCtl[RbacController<br/>/iam/viewports, permissions, roles]
UserCtl[UserController<br/>P2 新增: 用户 CRUD]
RoleCtl[RoleController<br/>P2 新增: 角色 CRUD]
ViewportCtl[ViewportController<br/>P2 新增: 视口 CRUD]
JwksCtl[JwksController<br/>P2 新增: RS256 公钥暴露]
subgraph Controllers["Controller 层(双入口)"]
IamCtl[IamController<br/>REST /v1/iam/*]
RbacCtl[RbacController<br/>REST /v1/iam/roles, /permissions]
AuditCtl[AuditController<br/>REST /v1/iam/audit]
JwksCtl[JwksController<br/>GET /v1/iam/.well-known/jwks.json]
GrpcCtl[IamGrpcController<br/>12 RPC @ GrpcMethod]
end
subgraph Services["Application Service 层"]
IamSvc[IamService<br/>认证编排]
RbacSvc[RbacService<br/>RBAC 编排]
UserSvc[UserService<br/>用户领域编排]
CacheSvc[PermissionCacheService<br/>Redis 权限缓存]
end
subgraph Domain["Domain 层P2 轻量)"]
UserEntity[UserEntity<br/>聚合根]
RoleEntity[RoleEntity<br/>聚合根]
IamSvc[IamService<br/>认证 + RBAC + 审计编排<br/>REST 与 gRPC 共用]
CacheSvc[PermissionCacheService<br/>Redis 权限缓存 TTL 5min]
BlacklistSvc[TokenBlacklistService<br/>Redis jti 黑名单]
JwksSvc[JwksService<br/>PEM→JWK 转换]
end
subgraph Repo["Repository 层"]
IamRepo[IamRepository<br/>Drizzle 查询]
RbacRepo[RbacRepository<br/>Drizzle 查询]
IamRepo[IamRepository<br/>Drizzle 查询 10 表]
end
subgraph Outbox["Outbox 模块"]
OutboxTbl[(iam_outbox 表)]
Relay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
subgraph Outbox["Outbox 模块shared-ts"]
OutboxPub[OutboxPublisher<br/>事务内写入]
OutboxRelay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
end
subgraph Infra["基础设施"]
Db[(MySQL<br/>iam_db)]
Redis[(Redis<br/>权限缓存 + token 黑名单)]
Kafka[(Kafka<br/>edu.identity.user.* topic)]
Kafka[(Kafka<br/>edu.iam.* topics)]
end
end
Req --> MW
REST --> MW
MW --> Guard
GRPC --> Guard
Guard --> Controllers
Controllers --> Services
Services --> Domain
Services --> Repo
Services --> CacheSvc
Repo --> Db
Controllers --> IamSvc
IamSvc --> IamRepo
IamSvc --> CacheSvc
IamSvc --> BlacklistSvc
IamSvc --> OutboxPub
IamRepo --> Db
CacheSvc --> Redis
Services --> OutboxTbl
OutboxTbl --> Relay
Relay --> Kafka
BlacklistSvc --> Redis
OutboxPub --> OutboxRelay
OutboxRelay --> Kafka
Controllers -.异常.-> Filter
```
@@ -81,64 +81,50 @@ flowchart TB
```
请求进入
→ AuthMiddlewareP2 仍不注册Controller 直读 header
→ PermissionGuardAPP_GUARDDEV_MODE 旁路 + DB 驱动权限校验
→ Controller HandlerZod 校验 body
→ Application Service业务编排
→ AuthMiddleware注册于 me/logout/viewports/permissions/roles/audit/children
→ PermissionGuardAPP_GUARDdata_scope=all 直放行 + DB 驱动 + Redis 缓存
→ Controller HandlerZod 校验 body / @GrpcMethod
→ Application Service业务编排Repository + Cache + Outbox + 审计
→ RepositoryDrizzle 查询)
→ 异常抛出
→ GlobalErrorFilter统一兜底注入 traceId
→ 响应返回
```
### 1.3 目录结构(P2 目标态)
### 1.3 目录结构(最终态)
```
services/iam/src/
├─ iam/ # 限界上下文:认证
│ ├─ iam.controller.ts # 认证端点register/login/refresh/me/logout
│ ├─ iam.service.ts # 认证编排
│ ├─ iam.repository.ts # 用户/refresh_token 查询
│ ├─ iam.schema.ts # users / refresh_tokens 表
│ ├─ iam.dto.ts # Zod schema
domain/
└─ user.entity.ts # UserEntity 聚合根P2 新增
├─ rbac/ # 限界上下文RBACP2 从 iam/ 拆出
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
role.controller.ts # 角色 CRUDP2 新增)
│ ├─ permission.controller.ts # 权限点 CRUDP2 新增)
│ ├─ viewport.controller.ts # 视口配置 CRUDP2 新增)
│ ├─ rbac.service.ts # RBAC 编排
│ ├─ rbac.repository.ts # 角色/权限/视口查询
│ ├─ rbac.schema.ts # roles / permissions / role_permissions / role_viewports 表
│ └─ domain/
│ └─ role.entity.ts # RoleEntity 聚合根P2 新增)
├─ jwks/ # 限界上下文JWT 公钥暴露P2 新增)
│ ├─ jwks.controller.ts # GET /iam/.well-known/jwks.json
│ ├─ jwks.service.ts # 密钥加载 + JWK Set 生成
│ └─ jwks.repository.ts # 密钥元数据持久化(可选)
├─ cache/ # 限界上下文Redis 缓存P2 新增)
│ ├─ permission-cache.service.ts # getEffectivePermissions 缓存
│ └─ token-blacklist.service.ts # refresh token 黑名单
├─ outbox/ # Outbox 模式P2 新增)
│ ├─ outbox.schema.ts # iam_outbox 表
│ ├─ outbox.publisher.ts # 写入 outbox事务内
│ └─ outbox.relay-worker.ts # 后台轮询 + Kafka 投递
├─ iam/ # 限界上下文:认证 + RBAC + 审计
│ ├─ iam.controller.ts # REST /v1/iam 入口
│ ├─ iam.grpc.controller.ts # gRPC 12 RPC 入口
│ ├─ rbac.controller.ts # REST /v1/iam/roles, /permissions
│ ├─ audit.controller.ts # REST /v1/iam/audit
│ ├─ jwks.controller.ts # REST /v1/iam/.well-known/jwks.json
jwks.service.ts # PEM→JWK 转换
├─ iam.service.ts # Application Service12 RPC + Outbox + 审计
│ ├─ iam.repository.ts # Drizzle 查询10 表
│ ├─ iam.schema.ts # 10 张表 schema + DataScope/RoleType 枚举
iam.dto.ts # Zod schema
├─ config/
│ ├─ database.ts # Drizzle 池(已有)
│ ├─ redis.ts # Redis 客户端P2 新增)
│ ├─ jwt.ts # RS256 密钥加载P2 新增)
env.ts # 环境变量P2 扩展)
│ ├─ database.ts # Drizzle 池 + getDbInstance + closeDb
│ ├─ redis.ts # Redis 单例
│ ├─ jwt.ts # RS256 密钥加载 + ttlToSeconds
kafka.ts # Kafka producer 单例 + IAM_KAFKA_TOPICS
│ └─ env.ts # Zod 校验环境变量
├─ middleware/
│ ├─ auth.middleware.ts # 保留P2 仍不注册)
│ └─ permission.guard.ts # 改造:DB 驱动 + 缓存
│ ├─ auth.middleware.ts # 解析 x-user-* 头
│ └─ permission.guard.ts # DB 驱动 + Redis 缓存权限校验
├─ shared/
│ ├─ errors/ # 已有
│ ├─ health/ # 已有
├─ lifecycle/ # 改造:关闭顺序 HTTP→Kafka→Redis→DB
observability/ # 已有
├─ app.module.ts # 改造imports 新增 OutboxModule、CacheModule、JwksModule
└─ main.ts # 改造:启动 OutboxRelayWorker
│ ├─ cache/
│ ├─ permission-cache.service.ts # Redis 权限缓存
│ └─ token-blacklist.service.ts # Redis jti 黑名单
errors/ # ApplicationError 层次 + GlobalErrorFilter
│ ├─ health/ # /healthz + /readyz5 依赖检查)
│ ├─ lifecycle/ # 优雅停机Kafka→Redis→DB
│ └─ observability/ # logger / metrics / tracer
├─ app.module.ts # 根模块IamModule + HealthModule + OutboxModule + APP_GUARD
└─ main.ts # Hybrid 启动gRPC + HTTP
```
## 2. 领域模型
@@ -154,12 +140,7 @@ classDiagram
-name: string
-status: UserStatus
-dataScope: DataScope
-createdAt: Date
-updatedAt: Date
+create(props) UserEntity$
+rename(name) UserRenamedEvent
+disable() UserDisabledEvent
+changeDataScope(scope) UserDataScopeChangedEvent
-passwordChangedAt: Date
+verifyPassword(plain) bool
}
@@ -168,8 +149,7 @@ classDiagram
-name: string
-description: string?
-roleType: RoleType
+create(props) RoleEntity$
+rename(name) RoleRenamedEvent
-level: int
}
class Permission {
@@ -185,24 +165,44 @@ classDiagram
+viewportKey: string
+label: string
+route: string
+sortOrder: string
+level: ViewportLevel
+sortOrder: int
+requiredPermission: string?
+componentConfig: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+jti: string
+expiresAt: Date
+revokedAt: Date?
+isRevoked() bool
+isExpired() bool
}
class StudentGuardian {
+id: string
+studentId: string
+guardianId: string
+relation: string
}
class AuditLog {
+id: string
+actorUserId: string?
+action: string
+resourceType: string
+resourceId: string
+beforeState: string?
+afterState: string?
+ipAddress: string?
+userAgent: string?
}
UserEntity "1" --> "many" RefreshToken : 拥有
UserEntity "1" --> "many" StudentGuardian : 作为 guardian
RoleEntity "1" --> "many" Permission : 通过 role_permissions
RoleEntity "1" --> "many" RoleViewport : 配置
UserEntity "1" --> "many" AuditLog : 作为 actor
```
### 2.2 值对象(枚举)
@@ -211,194 +211,111 @@ classDiagram
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING = "pending", // P2 新增:注册后待激活
}
// DataScope 6 级I8 裁决SUBJECT 替代 DISTRICT
enum DataScope {
SELF = "self", // L0
CLASS = "class", // L1
GRADE = "grade", // L2
SCHOOL = "school", // L3
DISTRICT = "district", // L4
ALL = "all", // L5
SELF = "self", // L0:仅自己
SUBJECT = "subject", // L1:科目(教师所教科目+班级)
CLASS = "class", // L2:班级(班主任所带班级)
GRADE = "grade", // L3:年级
SCHOOL = "school", // L4:全校
ALL = "all", // L5:跨校(系统管理员)
}
// 三层角色模型§2.15
enum RoleType {
// P2 新增:三层角色模型
SYSTEM = "system", // 系统预设admin/teacher/student/parent
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长)
TEMPORARY = "temporary", // 临时授权(代课教师)
SYSTEM = "system", // 系统预设admin/teacher/student/parentlevel=0
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长level=1
TEMPORARY = "temporary", // 临时授权代课教师level=2
}
enum ViewportLevel {
ADMIN = "admin",
TEACHER = "teacher",
STUDENT = "student",
PARENT = "parent",
}
```
### 2.3 聚合间通信
- **同服务内**`IamService` 直接调用 `RbacService``PermissionCacheService`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接调用其他服务
- **同服务内**`IamService` 直接调用 `PermissionCacheService``TokenBlacklistService``JwksService``IamRepository`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接访问其他服务 DB
## 3. 数据模型
### 3.1 表清单(P2 目标态
### 3.1 表清单(10 张表
#### 3.1.1 已有表(保留)
| 表名 | 用途 | 主键 | 唯一索引 |
| ---------------------- | -------------------- | ----------------------------- | ----------------------- |
| `iam_users` | 用户主表 | `id` (char36) | `email` |
| `iam_roles` | 角色表 | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `(userId, roleId)` 复合 | — |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `(roleId, permissionId)` 复合 | — |
| `iam_refresh_tokens` | refresh token 持久化 | `id` | — |
| `iam_role_viewports` | 角色-视口配置 | `id` | `(roleId, viewportKey)` |
#### 3.1.2 P2 新增表
| 表名 | 用途 | 主键 | 唯一索引 |
| ------------------------------ | ------------------------------- | ---- | ----------------------- |
| `iam_outbox` | Outbox 事件表(事务内写入) | `id` | — |
| `iam_parent_student_relations` | 家长-学生关系表 | `id` | `(parentId, studentId)` |
| `iam_user_sessions` | 用户会话记录(审计 + 强制下线) | `id` | `userId + deviceHash` |
> **注**`class_subject_teachers` 表归属 core-edu见 §8.1 决策点 8不在 iam。
#### 3.1.3 P2 表结构定义
```typescript
// iam_outboxOutbox 事件表
export const iamOutbox = mysqlTable(
"iam_outbox",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), // 'User' | 'Role'
eventType: varchar("event_type", { length: 100 }).notNull(), // 'UserRegistered' | ...
payload: text("payload").notNull(), // JSON 序列化
topic: varchar("topic", { length: 100 }).notNull(), // 'edu.identity.user.created'
status: mysqlEnum("status", ["pending", "published", "failed"])
.notNull()
.default("pending"),
retryCount: int("retry_count").notNull().default(0),
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
statusIdx: index("idx_outbox_status").on(table.status), // relay 轮询用
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateId),
}),
);
// iam_parent_student_relations家长-学生关系
export const parentStudentRelations = mysqlTable(
"iam_parent_student_relations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
parentId: char("parent_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
relation: varchar("relation", { length: 20 }).notNull(), // 'father' | 'mother' | 'guardian'
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
parentStudentUniq: uniqueIndex("uniq_parent_student").on(
table.parentId,
table.studentId,
),
studentIdx: index("idx_student").on(table.studentId),
}),
);
// iam_roles 表扩展:新增 role_type 字段
// 在现有 iam_roles 表 ALTER ADD:
// role_type ENUM('system','organization','temporary') NOT NULL DEFAULT 'system'
// level INT NOT NULL DEFAULT 0 -- 三层优先级system=0(最高) / organization=1 / temporary=2
```
| 表名 | 用途 | 主键 | 唯一索引 |
| ----------------------- | ---------------------------- | ---- | ------------------------- |
| `iam_users` | 用户主表 | `id` | `email` |
| `iam_roles` | 角色表role_type + level | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `id` | `(userId, roleId)` |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `id` | `(roleId, permissionId)` |
| `iam_refresh_tokens` | refresh token含 jti | `id` | `jti` |
| `iam_role_viewports` | 角色-视口配置(含 level | `id` | `(roleId, viewportKey)` |
| `iam_student_guardians` | 学生-家长关系I6 裁决表名) | `id` | `(studentId, guardianId)` |
| `iam_user_audit_log` | 审计日志§5.5 | `id` | — |
| `iam_password_history` | 密码重用限制 | `id` | — |
### 3.2 索引策略
| 表 | 索引 | 用途 |
| ------------------------------ | -------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录查询 |
| `iam_user_roles` | INDEX(`userId`)、INDEX(`roleId`) | 按用户查角色、按角色查用户 |
| `iam_role_permissions` | INDEX(`roleId`)、INDEX(`permissionId`) | 按角色查权限 |
| `iam_refresh_tokens` | INDEX(`userId`)、INDEX(`tokenHash`) | 按用户查 token、按 hash 校验 |
| `iam_role_viewports` | INDEX(`roleId`) | 按角色查视口 |
| `iam_outbox` | INDEX(`status`)、INDEX(`aggregateId`) | relay 轮询 pending、按聚合查事件 |
| `iam_parent_student_relations` | UNIQUE(`parentId`,`studentId`)、INDEX(`studentId`) | 防重、按学生查家长 |
| 表 | 索引 | 用途 |
| ----------------------- | ---------------------------------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录 |
| `iam_user_roles` | UNIQUE(`userId`,`roleId`)、INDEX(`roleId`) | 防重、按角色查用户 |
| `iam_role_permissions` | UNIQUE(`roleId`,`permissionId`)、INDEX(`permissionId`) | 防重、按权限查角色 |
| `iam_refresh_tokens` | UNIQUE(`jti`)、INDEX(`userId`)、INDEX(`tokenHash`) | jti 黑名单、按用户、按 hash 校验 |
| `iam_role_viewports` | UNIQUE(`roleId`,`viewportKey`) | 防重 |
| `iam_student_guardians` | UNIQUE(`studentId`,`guardianId`)、INDEX(`guardianId`) | 防重、按家长查学生 |
| `iam_user_audit_log` | INDEX(`actorUserId`)、INDEX(`resourceType`,`resourceId`)、INDEX(`createdAt`) | 按操作者、按资源、按时间查审计 |
| `iam_password_history` | INDEX(`userId`,`createdAt`) | 按用户查密码历史 |
### 3.3 读写分离策略
- **写路径**:所有 Command 走 MySQL 主库iam 独占库,无读写分离
- **读路径**P2 暂不引入 ClickHouse 读模型iam 读多写少但数据量小MySQL 足够
- **缓存层**`getEffectivePermissions` / `getUserViewports` 结果走 Redis 缓存TTL 5min
- **写路径**:所有 Command 走 MySQL 主库iam 独占库)
- **读路径**MySQL 直接读iam 读多写少但数据量小,无需 ClickHouse 读模型
- **缓存层**
- `getEffectivePermissions` 结果走 Redis 缓存TTL 5minKey: `iam:perm:{userId}`
- refresh token jti 黑名单走 RedisKey: `iam:bl:{jti}`TTL 与 refresh_token 剩余有效期对齐
## 4. API 设计
### 4.1 REST API 完整清单P2 目标态)
### 4.1 REST API 清单
| Method | Path | 权限 | 请求体 / 参数 | 响应 | 说明 |
| ------ | ------------------------------------------ | ----------------- | ---------------------------------- | ----------------- | -------------------------------------- |
| POST | `/iam/register` | 公开 | `{email, password, name}` | `{user, tokens}` | 注册 + 自动分配 teacher 角色 |
| POST | `/iam/login` | 公开 | `{email, password}` | `{user, tokens}` | 登录 |
| POST | `/iam/refresh` | 公开 | `{refreshToken}` | `{tokens}` | 刷新令牌(轮换 + 旧 token黑名单) |
| POST | `/iam/logout` | `IAM_USER_READ` | `{refreshToken}` | `{success}` | 登出refresh token 加黑名单) |
| GET | `/iam/me` | `IAM_USER_READ` | — | `{user}` | 当前用户信息 |
| GET | `/iam/viewports` | `IAM_USER_READ` | — | `{viewports[]}` | 当前用户视口L1 导航) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | — | `{permissions[]}` | 当前用户有效权限 |
| GET | `/iam/.well-known/jwks.json` | 公开 | — | `{keys[]}` | RS256 公钥 JWK SetGateway 拉取) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 角色列表 |
| POST | `/iam/roles` | `IAM_ROLE_MANAGE` | `{name, description, roleType}` | `{role}` | 创建角色 |
| PUT | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | `{name?, description?}` | `{role}` | 更新角色 |
| DELETE | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除角色(系统角色禁止删) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 权限点列表 |
| GET | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 用户角色列表 |
| POST | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | `{roleId}` | `{success}` | 给用户分配角色 |
| DELETE | `/iam/users/:id/roles/:roleId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除用户角色(触发缓存失效) |
| GET | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 角色权限列表 |
| POST | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | `{permissionId}` | `{success}` | 给角色授予权限 |
| DELETE | `/iam/roles/:id/permissions/:permissionId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除角色权限(触发缓存失效) |
| GET | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | — | `{viewports[]}` | 角色视口列表 |
| POST | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | `{viewportKey, label, route, ...}` | `{viewport}` | 创建视口配置 |
| PUT | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | `{label?, route?, ...}` | `{viewport}` | 更新视口配置 |
| DELETE | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除视口配置 |
| GET | `/iam/users/:id/parents` | `IAM_USER_READ` | — | `{parents[]}` | 学生家长列表(家长-学生关系) |
| POST | `/iam/users/:studentId/parents` | `IAM_ROLE_MANAGE` | `{parentId, relation}` | `{success}` | 绑定家长-学生关系 |
| Method | Path | 权限 | 说明 |
| ------ | ------------------------------- | ----------------- | -------------------------------------- |
| POST | `/v1/iam/register` | 公开 | 注册 |
| POST | `/v1/iam/login` | 公开 | 登录 |
| POST | `/v1/iam/refresh` | 公开 | 刷新令牌(轮换 + 旧 jti 加黑名单) |
| POST | `/v1/iam/logout` | `IAM_USER_READ` | 登出refresh token 加黑名单) |
| GET | `/v1/iam/me` | `IAM_USER_READ` | 当前用户信息 |
| GET | `/v1/iam/viewports` | `IAM_USER_READ` | 当前用户视口 |
| GET | `/v1/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限 |
| GET | `/v1/iam/children` | `IAM_USER_READ` | 家长的孩子列表 |
| GET | `/v1/iam/roles` | `IAM_ROLE_MANAGE` | 角色列表 |
| GET | `/v1/iam/permissions` | `IAM_ROLE_MANAGE` | 权限点列表 |
| GET | `/v1/iam/audit` | `IAM_AUDIT_READ` | 审计日志(支持 actor/resource 查询) |
| GET | `/v1/iam/.well-known/jwks.json` | 公开 | RS256 公钥 JWK SetGateway 拉取验签) |
### 4.2 请求/响应结构示例
### 4.2 gRPC API 清单12 RPCpackage `next_edu_cloud.iam.v1`
```typescript
// 注册响应
interface RegisterResponse {
success: true;
data: {
user: {
id: string;
email: string;
name: string;
roles: string[]; // ['teacher']
permissions: string[]; // ['IAM_USER_READ', 'CLASSES_READ', ...]
dataScope: "self" | "class" | "grade" | "school" | "district" | "all";
};
tokens: {
accessToken: string; // RS256 签名15min
refreshToken: string; // RS256 签名7day
expiresIn: 900; // 秒
};
};
}
// JWK Set 响应RS256 公钥暴露)
interface JwkSet {
keys: Array<{
kty: "RSA";
use: "sig";
alg: "RS256";
kid: string; // 密钥 ID支持轮换
n: string; // modulus base64url
e: string; // exponent base64url
}>;
}
```
| RPC | 请求 | 响应 | 说明 |
| ------------------------- | ------------------------- | ---------------------- | -------------------------- |
| `Register` | `{email, password, name}` | `{user, tokens}` | 注册 |
| `Login` | `{email, password}` | `{user, tokens}` | 登录 |
| `RefreshToken` | `{refreshToken}` | `{tokens}` | 刷新令牌 |
| `Logout` | `{refreshToken, userId}` | `{success}` | 登出 |
| `GetUserInfo` | `{userId}` | `UserInfo` | 查询用户信息 |
| `BatchGetUsers` | `{userIds[]}` | `{users[]}` | 批量查询用户BFF 聚合用) |
| `GetEffectivePermissions` | `{userId}` | `{permissions[]}` | 用户有效权限 |
| `GetEffectiveAccess` | `{userId, permission}` | `{allowed, dataScope}` | 用户对某权限的有效访问判定 |
| `GetEffectiveDataScope` | `{userId}` | `{dataScope}` | 用户数据范围 |
| `GetViewports` | `{userId}` | `{viewports[]}` | 用户视口 |
| `GetPublicKey` | — | `{kid, alg, pem}` | 公钥拉取(备用 JWKS |
| `GetChildrenByParent` | `{parentId}` | `{children[]}` | 家长的孩子列表 |
### 4.3 JWT PayloadRS256 签发)
@@ -411,70 +328,85 @@ interface JwtPayload {
type: "access" | "refresh";
iat: number; // 签发时间
exp: number; // 过期时间
iss: "next-edu-cloud"; // 签发者
aud: "next-edu-cloud"; // 受众
jti: string; // JWT ID用于黑名单
iss: "next-edu-cloud";
aud: "next-edu-cloud";
jti: string; // JWT ID仅 refresh token用于黑名单)
kid: string; // 密钥 IDheader支持 JWKS 轮换)
}
```
- **access_token**TTL 15min`ACCESS_TOKEN_TTL`RS256 签名
- **refresh_token**TTL 7day`REFRESH_TOKEN_TTL_DAYS`RS256 签名,含 jti
- **密钥**:本地文件(`IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`),启动时一次性加载
## 5. 事件设计
### 5.1 我发布的领域事件
| 事件 | 触发时机 | Topic | 消费者动作 |
| ----------------- | ------------------------------ | -------------------------------- | -------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu 初始化默认班级关联msg 发欢迎通知 |
| `UserUpdated` | 用户信息变更name/dataScope | `edu.identity.user.updated` | core-edu 同步用户快照msg 通知 |
| `UserDisabled` | 用户禁用/注销 | `edu.identity.user.deleted` | core-edu 解除关联msg 通知push-gateway 强制下线 |
| `UserRoleChanged` | 用户角色绑定变更 | `edu.identity.user.role_changed` | 自身 Redis 缓存失效msg 审计日志 |
| `RoleCreated` | 角色创建 | `edu.identity.role.created` | msg 审计(仅管理端关注) |
| `RoleUpdated` | 角色权限变更 | `edu.identity.role.updated` | 所有该角色用户的缓存失效msg 审计 |
| 事件 | 触发时机 | Topic | 消费者 |
| -------------- | ------------------------------------ | ----------------------- | -------------- |
| `UserCreated` | 注册成功 | `edu.iam.user.events` | core-edu / msg |
| `AuditCreated` | 关键操作(登录/角色变更/密码修改等) | `edu.iam.audit.created` | data-ana |
| `RoleChanged` | 角色绑定变更 | `edu.iam.role.events` | msg / 缓存失效 |
### 5.2 事件 Schema建议 coord 在 shared-proto 中统一定义
### 5.2 事件 Schemaevents.proto
```protobuf
// 建议在 packages/shared-proto/proto/events.proto 新增:
message UserEvent {
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // userId
string event_type = 3; // 'UserRegistered' | 'UserUpdated' | ...
int64 occurred_at = 4; // 发生时间戳ms
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string user_id = 5;
string email = 6;
string name = 7;
repeated string roles = 8;
string data_scope = 9;
string action = 10; // 'created' | 'updated' | 'disabled' | 'role_changed'
map<string, string> metadata = 11; // trace_id 等
string action = 10;
map<string, string> metadata = 11;
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2; // roleId
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
string action = 7; // 'created' | 'updated' | 'deleted'
string action = 7;
map<string, string> metadata = 8;
}
message AuditEvent {
string event_id = 1;
string actor_user_id = 2;
string action = 3;
string resource_type = 4;
string resource_id = 5;
int64 occurred_at = 6;
string ip_address = 7;
map<string, string> metadata = 8;
}
```
> **需 coord 在 shared-proto/events.proto 中统一定义**iam 只负责填充字段并写入 outbox。
### 5.3 我消费的事件
- **当前**:无
- **未来**不主动消费业务事件iam 是权限中枢,单向发布)
无。iam 是权限中枢,单向发布,不消费业务事件。
### 5.4 Outbox 实现策略
- **shared-ts OutboxModule**I4 裁决):`OutboxModule.forRoot({ config, db, kafkaProducer })`
- **表名**`iam_outbox`
- **topic**`edu.iam.user.events`(默认)/ `edu.iam.role.events` / `edu.iam.audit.created`
- **轮询间隔**1000ms批量 20 条,最大重试 5 次,退避 1000ms
- **幂等性**producer `idempotent=true` + `transactionalId: "iam-tx"`consumer 基于 `event_id` 去重
```mermaid
sequenceDiagram
participant Ctl as Controller
participant Svc as IamService
participant DB as MySQL
participant Outbox as iam_outbox 表
participant Pub as OutboxPublisher
participant Relay as OutboxRelayWorker
participant Kafka as Kafka
@@ -482,77 +414,37 @@ sequenceDiagram
Svc->>DB: BEGIN TX
Svc->>DB: INSERT iam_users
Svc->>DB: INSERT iam_user_roles
Svc->>Outbox: INSERT event (status=pending)
Svc->>DB: INSERT iam_user_audit_log
Svc->>Pub: publish(UserCreated + AuditCreated)
Pub->>DB: INSERT iam_outbox
Svc->>DB: COMMIT TX
Svc-->>Ctl: {user, tokens}
loop 每 100ms 轮询
Relay->>Outbox: SELECT * WHERE status='pending' LIMIT 100
Outbox-->>Relay: events[]
loop 每 1000ms 轮询
Relay->>DB: SELECT pending LIMIT 20
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>Outbox: UPDATE status='published', published_at=NOW()
Relay->>DB: UPDATE status='published'
end
```
**Relay Worker 实现**
- 独立 `@Injectable()` 服务,`OnModuleInit` 启动轮询
- 每 100ms 查询 `status='pending'` 的事件,批量投递 Kafka
- 投递失败重试 3 次,超过后标记 `status='failed'`,记录日志
- Kafka 未启动时不阻塞主服务try/catch + 日志警告)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(所有端点及对应权限常量)
### 6.1 权限装饰器
| 端点 | 权限常量 |
| ----------------------------------------------- | ----------------- |
| POST /iam/register | 公开(无装饰器) |
| POST /iam/login | 公开 |
| POST /iam/refresh | 公开 |
| GET /iam/.well-known/jwks.json | 公开 |
| POST /iam/logout | `IAM_USER_READ` |
| GET /iam/me | `IAM_USER_READ` |
| GET /iam/viewports | `IAM_USER_READ` |
| GET /iam/permissions/effective | `IAM_USER_READ` |
| GET /iam/users/:id/parents | `IAM_USER_READ` |
| GET /iam/roles | `IAM_ROLE_MANAGE` |
| POST /iam/roles | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id | `IAM_ROLE_MANAGE` |
| GET /iam/permissions | `IAM_ROLE_MANAGE` |
| GET /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| POST /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| DELETE /iam/users/:id/roles/:roleId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/permissions/:permissionId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| POST /iam/users/:studentId/parents | `IAM_ROLE_MANAGE` |
**权限常量清单**P2 完整化):
每个 Controller 方法用 `@RequirePermission()` 装饰器声明权限点。权限常量
```typescript
export const Permissions = {
// 用户管理
IAM_USER_CREATE: "IAM_USER_CREATE",
IAM_USER_READ: "IAM_USER_READ",
IAM_USER_UPDATE: "IAM_USER_UPDATE",
IAM_USER_DELETE: "IAM_USER_DELETE",
// 角色管理
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE",
// 视口管理
IAM_VIEWPORT_MANAGE: "IAM_VIEWPORT_MANAGE",
// 家长-学生关系管理
IAM_RELATION_MANAGE: "IAM_RELATION_MANAGE",
IAM_USER_READ: "iam:user:read",
IAM_USER_MANAGE: "iam:user:manage",
IAM_ROLE_MANAGE: "iam:role:manage",
IAM_AUDIT_READ: "iam:audit:read",
IAM_VIEWPORT_READ: "iam:viewport:read",
} as const;
```
### 6.2 错误码清单(带前缀)
### 6.2 错误码清单
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------------------------------- |
@@ -562,157 +454,109 @@ export const Permissions = {
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
| `IAM_RATE_LIMITED` | 429 | 登录失败次数过多P2 可选,限流在 Gateway |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 Logger 初始化位置与配置
### 6.3 可观测性
- **位置**`src/shared/observability/logger.ts`(已有)
- **配置**pino`base: { service: 'iam', version: '0.1.0' }``level: env.LOG_LEVEL`
- **P2 新增**:日志中注入 `traceId`(从 `x-request-id` 头读取OTel auto-instrumentation 已覆盖)
- **日志**pino`base: { service: 'iam', version: '0.2.0' }``level: env.LOG_LEVEL`,注入 traceId
- **指标**prom-client`/metrics` 端点
- `iam_login_attempts_total``iam_login_duration_seconds`
- `iam_jwt_issued_total`type=access/refresh
- `iam_permission_cache_hits_total` / `iam_permission_cache_misses_total`
- `iam_outbox_pending``iam_outbox_publish_duration_seconds`
- **链路**OpenTelemetry SDK + OTLP exporterserviceName: `iam`
### 6.4 Metrics 指标清单
### 6.4 健康检查
| 指标名 | 类型 | 标签 | 描述 |
| ------------------------------------- | --------- | ------------------------ | ------------------------------ |
| `iam_requests_total` | Counter | method, endpoint, status | 请求总数(已有) |
| `iam_request_duration_seconds` | Histogram | method, endpoint | 请求延迟(已有) |
| `iam_login_attempts_total` | Counter | result(success/failure) | 登录尝试次数P2 新增) |
| `iam_login_duration_seconds` | Histogram | — | 登录耗时P2 新增) |
| `iam_jwt_issued_total` | Counter | type(access/refresh) | JWT 签发次数P2 新增) |
| `iam_permission_cache_hits_total` | Counter | — | 权限缓存命中P2 新增) |
| `iam_permission_cache_misses_total` | Counter | — | 权限缓存未命中P2 新增) |
| `iam_outbox_pending` | Gauge | — | Outbox 待投递事件数P2 新增) |
| `iam_outbox_publish_duration_seconds` | Histogram | — | Outbox 投递耗时P2 新增) |
- **`/healthz`**liveness仅返回进程存活
- 响应:`{ status: 'ok', service: 'iam', timestamp: ISO }`
- **`/readyz`**readiness5 依赖检查
- DB`SELECT 1`
- Redis`ping`
- Kafkaproducer 实例存在且已连接
- JWKS密钥文件可读
- gRPC进程内 microservice 存在
- 失败HTTP 503响应体含失败项详情
### 6.5 Tracer 初始化位置
- **位置**`src/shared/observability/tracer.ts`(已有)
- **配置**NodeSDK + OTLP HTTP exporter`serviceName: 'iam'`
- **P2 保持**auto-instrumentations 覆盖 HTTP/Express/Drizzlemysql2
### 6.6 /healthz 检查逻辑
- **端点**`GET /healthz`
- **逻辑**:仅返回进程存活,不检查依赖
- **响应**`{ status: 'ok', service: 'iam', timestamp: ISO }`
### 6.7 /readyz 检查逻辑P2 改造)
- **端点**`GET /readyz`
- **逻辑**P2 新增 Redis + Kafka 检查):
```typescript
async readiness() {
const checks = await Promise.allSettled([
this.checkDb(), // db.execute(sql`SELECT 1`)
this.checkRedis(), // redis.ping()
this.checkKafka(), // kafka.admin().listTopics()(轻量探活)
]);
const allOk = checks.every(r => r.status === 'fulfilled');
if (!allOk) throw 503;
return { status: 'ok', service: 'iam', timestamp, checks };
}
```
- **失败**HTTP 503响应体含失败项详情
### 6.8 优雅关闭顺序P2 改造)
### 6.5 优雅关闭顺序
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求NestJS 自动)
// 2. 停止 OutboxRelayWorker停止轮询
await this.relayWorker.stop();
// 2. 停止 OutboxRelayWorkershared-ts 自动
// 3. 关闭 Kafka producer
await this.kafkaProducer.disconnect();
await closeKafkaProducer();
// 4. 关闭 Redis 连接
await this.redisClient.quit();
await closeRedis();
// 5. 关闭 MySQL 连接池
await closeDb();
// 6. 关闭 Tracer
await shutdownTracer();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------- | -------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /iam/register` 等 | Gateway 反向代理 `/api/v1/iam/*` |
| 被调用 | teacher-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | 同上P3 |
| 被调用 | parent-bff | HTTP | `GET /iam/me`、`GET /iam/users/:id/parents` | 同上 + 家长-学生关系P4 |
| 被调用 | api-gateway | HTTP | `GET /iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 发布 | — | Kafka | `edu.identity.user.created` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.updated` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.deleted` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.role_changed` | 自身缓存失效 / msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.created` | msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.updated` | 自身缓存失效 / msg 审计 |
| 消费 | — | — | — | iam 不消费外部事件 |
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------------- | ------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /v1/iam/register` | Gateway 反向代理 `/iam/v1/*` |
| 被调用 | api-gateway | HTTP | `GET /v1/iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 被调用 | teacher-bff | gRPC | `GetUserInfo``BatchGetUsers``GetViewports` 等 | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | gRPC | 同上 | 同上 |
| 被调用 | parent-bff | gRPC | `GetChildrenByParent` | 家长视角聚合孩子信息 |
| 发布 | — | Kafka | `edu.iam.user.events` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.iam.role.events` | msg / 缓存失效 |
| 发布 | — | Kafka | `edu.iam.audit.created` | data-ana 消费 |
### 7.1 端口分配
| 服务 | 端口 | 备注 |
| -------- | ----- | ------------------------------------------------------------ |
| iam HTTP | 3002 | 已有,REST 入口 |
| iam gRPC | 50052 | P3 引入(与 core-edu 50053 等区分,需 coord 全局端口表确认 |
| 服务 | 端口 | 备注 |
| -------- | ----- | -------------------- |
| iam HTTP | 3002 | REST 入口 |
| iam gRPC | 50052 | gRPC 入口I1 裁决 |
### 7.2 Topic 命名(遵循 004 §7.2
### 7.2 Topic 命名
- `edu.identity.user.created`
- `edu.identity.user.updated`
- `edu.identity.user.deleted`
- `edu.identity.user.role_changed`
- `edu.identity.role.created`
- `edu.identity.role.updated`
- `edu.iam.user.events`
- `edu.iam.role.events`
- `edu.iam.audit.created`
> **需 coord 在全局 Topic 表中登记**,避免与 core-edu 的 `edu.identity.user.created` 冲突004 §7.2 已记录 iam 为生产者)。
## 8. 仲裁裁决对齐
## 8. 风险与假设
### 8.1 I1-I8 裁决
### 8.1 架构决策点(需 coord 仲裁)
| 裁决 | 内容 | 实现位置 |
| ---- | ------------------------------------ | ------------------------------------------------- |
| I1 | 双入口REST + gRPCgRPC 50052 | main.ts Hybrid app + iam.grpc.controller.ts |
| I2 | JWT RS256 本地文件密钥 | config/jwt.ts + env IAM_PRIVATE/PUBLIC_KEY_PATH |
| I3 | DB 驱动 + Redis 缓存 PermissionGuard | permission.guard.ts + permission-cache.service.ts |
| I4 | shared-ts OutboxModule 接入 | app.module.ts OutboxModule.forRoot |
| I5 | /iam/v1/* 前缀 | iam.controller.ts @Controller("v1/iam") |
| I6 | iam_student_guardians 表名 | iam.schema.ts + iam-init.sql |
| I7 | jti 黑名单 | token-blacklist.service.ts |
| I8 | DataScope SUBJECT 替代 DISTRICT | iam.schema.ts DataScope enum |
| # | 决策点 | 我的建议 | 风险 |
| --- | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 1 | RS256 密钥管理 | P2 本地文件 `IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`P6 迁 Vault | 密钥文件权限管理;容器挂载 |
| 2 | 公钥暴露端点 | `GET /iam/.well-known/jwks.json`JWK Set 标准) | Gateway 需实现 JWK 解析 |
| 3 | 权限缓存失效 | 角色变更时本地主动 `DEL iam:perms:{userId}` + 发事件 | 缓存与 DB 短暂不一致(< 5min TTL |
| 4 | Outbox 实现 | iam 自建轻量 OutboxP2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
| 5 | gRPC 引入时机 | P2 仅 RESTP3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTPP3 改 gRPC 需协调 ai03 |
| 6 | DataScope 枚举 | schema 用字符串枚举proto 用数值枚举 L0-L5 映射 | 跨层映射需文档化 |
| 7 | `parent_student_relations` 归属 | 归 iam身份关系优先 | core-edu 查询家长需走 iam API |
| 8 | `class_subject_teachers` 归属 | 归 core-edu教学组织数据 | iam 只存 userId + 角色,不存任教关系 |
| 9 | PermissionGuard 改造 | DB 驱动 + Redis 缓存,废弃本地 ROLE_PERMISSIONS map | 性能:每次请求多一次缓存查询 |
| 10 | AuthMiddleware 注册 | P2 仍不注册Controller 直读 header与 Gateway 透传策略一致) | 多 Controller 重复读 header 代码 |
### 8.2 总裁裁决
### 8.2 技术风险
| 裁决 | 内容 | 实现位置 |
| ----- | --------------------------------------------- | -------------------------------------------- |
| §2.15 | 三层角色模型system/organization/temporary | iam.schema.ts RoleType + iam_roles.level |
| §2.16 | 双入口策略(同一 IamService | iam.service.ts REST 与 gRPC 共用 |
| §3.2 | AuthMiddleware 注册 | app.module.ts configure() |
| §5.5 | 审计日志iam_user_audit_log + AuditEvent | iam.schema.ts + iam.service.ts writeAuditLog |
## 9. 技术风险
| 风险 | 影响 | 缓解措施 |
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB不抛错 |
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 + 定期轮换P6 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 事件驱动失效 + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天)或归档到 ClickHouse |
### 8.3 假设
- 假设 coord 在 shared-proto/events.proto 中统一定义 `UserEvent` / `RoleEvent` messageiam 只填充字段)
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验ai01 负责)
- 假设 teacher-bff 仍走 HTTP 调用 iamai03 负责P3 改 gRPC 时协调)
- 假设 shared-ts 在 P2 不提供通用 Outbox 工具iam 自建P3 core-edu 落地后回写到 shared-ts
### 8.4 未决问题(需 coord 回复)
1. shared-ts 是否在 P2 提供通用 Outbox 工具?还是 iam 自建?
2. events.proto 中 `UserEvent` / `RoleEvent` 由 coord 统一定义,还是 iam 提交 PR
3. iam gRPC 端口 50052 是否与全局端口表冲突?
4. `class_subject_teachers` 表归属确认(我建议归 core-edupending-features §P2 写在 iam
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天) |
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai
**模块**: iam-service@edu/iam-service v0.2.0
**分支**: feat-implement-iam-module
**入口**: HTTP 3002 + gRPC 50052