Files
Edu/services/iam/docs/02-architecture-design.md
SpecialX a35e759d64 feat(iam): 完整实现 iam 身份认证与权限服务
包含 jwt/jwks/audit/grpc、rbac、cache、redis/kafka 配置等完整实现
2026-07-10 19:09:39 +08:00

563 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块架构设计文档 — iam
> 模块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)
---
## 1. 模块内部分层图
### 1.1 调用链总览
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
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 Hybrid App"]
direction TB
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/>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/>认证 + 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 查询 10 表]
end
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.iam.* topics)]
end
end
REST --> MW
MW --> Guard
GRPC --> Guard
Guard --> Controllers
Controllers --> IamSvc
IamSvc --> IamRepo
IamSvc --> CacheSvc
IamSvc --> BlacklistSvc
IamSvc --> OutboxPub
IamRepo --> Db
CacheSvc --> Redis
BlacklistSvc --> Redis
OutboxPub --> OutboxRelay
OutboxRelay --> Kafka
Controllers -.异常.-> Filter
```
### 1.2 中间件 / Guard / Filter 拦截顺序
```
请求进入
→ 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 目录结构(最终态)
```
services/iam/src/
├─ 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 池 + getDbInstance + closeDb
│ ├─ redis.ts # Redis 单例
│ ├─ jwt.ts # RS256 密钥加载 + ttlToSeconds
│ ├─ kafka.ts # Kafka producer 单例 + IAM_KAFKA_TOPICS
│ └─ env.ts # Zod 校验环境变量
├─ middleware/
│ ├─ auth.middleware.ts # 解析 x-user-* 头
│ └─ permission.guard.ts # DB 驱动 + Redis 缓存权限校验
├─ shared/
│ ├─ 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. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class UserEntity {
-id: string
-email: string
-passwordHash: string
-name: string
-status: UserStatus
-dataScope: DataScope
-passwordChangedAt: Date
+verifyPassword(plain) bool
}
class RoleEntity {
-id: string
-name: string
-description: string?
-roleType: RoleType
-level: int
}
class Permission {
+id: string
+name: string
+resource: string
+action: string
}
class RoleViewport {
+id: string
+roleId: string
+viewportKey: string
+label: string
+route: string
+level: ViewportLevel
+sortOrder: int
+requiredPermission: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+jti: string
+expiresAt: Date
+revokedAt: Date?
}
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 值对象(枚举)
```typescript
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
}
// DataScope 6 级I8 裁决SUBJECT 替代 DISTRICT
enum DataScope {
SELF = "self", // L0仅自己
SUBJECT = "subject", // L1科目教师所教科目+班级)
CLASS = "class", // L2班级班主任所带班级
GRADE = "grade", // L3年级
SCHOOL = "school", // L4全校
ALL = "all", // L5跨校系统管理员
}
// 三层角色模型§2.15
enum RoleType {
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` 直接调用 `PermissionCacheService``TokenBlacklistService``JwksService``IamRepository`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接访问其他服务 DB
## 3. 数据模型
### 3.1 表清单10 张表)
| 表名 | 用途 | 主键 | 唯一索引 |
| ----------------------- | ---------------------------- | ---- | ------------------------- |
| `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` | 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 独占库)
- **读路径**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 清单
| 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 gRPC API 清单12 RPCpackage `next_edu_cloud.iam.v1`
| 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 签发)
```typescript
interface JwtPayload {
sub: string; // userId
email: string;
roles: string[]; // ['teacher', 'grade_leader']
dataScope: DataScope; // 'class' | 'grade' | ...
type: "access" | "refresh";
iat: number; // 签发时间
exp: number; // 过期时间
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 | 消费者 |
| -------------- | ------------------------------------ | ----------------------- | -------------- |
| `UserCreated` | 注册成功 | `edu.iam.user.events` | core-edu / msg |
| `AuditCreated` | 关键操作(登录/角色变更/密码修改等) | `edu.iam.audit.created` | data-ana |
| `RoleChanged` | 角色绑定变更 | `edu.iam.role.events` | msg / 缓存失效 |
### 5.2 事件 Schemaevents.proto
```protobuf
message UserEvent {
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;
map<string, string> metadata = 11;
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
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;
}
```
### 5.3 我消费的事件
无。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 Pub as OutboxPublisher
participant Relay as OutboxRelayWorker
participant Kafka as Kafka
Ctl->>Svc: register(dto)
Svc->>DB: BEGIN TX
Svc->>DB: INSERT iam_users
Svc->>DB: INSERT iam_user_roles
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 每 1000ms 轮询
Relay->>DB: SELECT pending LIMIT 20
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>DB: UPDATE status='published'
end
```
## 6. 横切关注点对齐清单
### 6.1 权限装饰器
每个 Controller 方法用 `@RequirePermission()` 装饰器声明权限点。权限点常量:
```typescript
export const Permissions = {
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 错误码清单
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效、refresh token 在黑名单 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 可观测性
- **日志**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 健康检查
- **`/healthz`**liveness仅返回进程存活
- 响应:`{ status: 'ok', service: 'iam', timestamp: ISO }`
- **`/readyz`**readiness5 依赖检查
- DB`SELECT 1`
- Redis`ping`
- Kafkaproducer 实例存在且已连接
- JWKS密钥文件可读
- gRPC进程内 microservice 存在
- 失败HTTP 503响应体含失败项详情
### 6.5 优雅关闭顺序
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求NestJS 自动)
// 2. 停止 OutboxRelayWorkershared-ts 自动)
// 3. 关闭 Kafka producer
await closeKafkaProducer();
// 4. 关闭 Redis 连接
await closeRedis();
// 5. 关闭 MySQL 连接池
await closeDb();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------------- | ------------------------------- |
| 被调用 | 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 | gRPC 入口I1 裁决) |
### 7.2 Topic 命名
- `edu.iam.user.events`
- `edu.iam.role.events`
- `edu.iam.audit.created`
## 8. 仲裁裁决对齐
### 8.1 I1-I8 裁决
| 裁决 | 内容 | 实现位置 |
| ---- | ------------------------------------ | ------------------------------------------------- |
| 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 |
### 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 挂载 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天) |
---
**模块**: iam-service@edu/iam-service v0.2.0
**分支**: feat-implement-iam-module
**入口**: HTTP 3002 + gRPC 50052