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

@@ -233,26 +233,32 @@
| AppModule 注册 | HealthModule 必须在 `app.module.ts` imports 数组显式声明,否则 NestFactory 不扫描 HealthController | | AppModule 注册 | HealthModule 必须在 `app.module.ts` imports 数组显式声明,否则 NestFactory 不扫描 HealthController |
| 增量编译陷阱 | `tsconfig.json` 显式 `"incremental": false` 覆盖 base避免 .tsbuildinfo 导致 nest watch 不 emit | | 增量编译陷阱 | `tsconfig.json` 显式 `"incremental": false` 覆盖 base避免 .tsbuildinfo 导致 nest watch 不 emit |
### 2.3 iamTS/NestJSP2 ### 2.3 iamTS/NestJS
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 认证 | 登录/登出/JWT/2FARS256 非对称签名 | | 双入口 | REST `/v1/iam/*` + gRPC 50052package `next_edu_cloud.iam.v1`),同一 IamService 实例共用Hybrid app `connectMicroservice` |
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API | | 认证 | RS256 非对称签名,私钥本地文件(`IAM_PRIVATE_KEY_PATH`),公钥通过 JWKS 端点暴露给 Gateway |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 | | JWT payload | `{ sub, email, roles, dataScope, type, iss, aud, jti(仅 refresh), kid(header) }`register 自动分配 teacher 角色 |
| DataScope 解析 | 6 级数据范围,注入 JWT payload | | Token TTL | access 15min / refresh 7dayrefresh 含 jti轮换时旧 jti 加入 Redis 黑名单 |
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` | | RBAC | 角色/权限/角色-权限查询 + `getEffectivePermissions(userId)` API三层角色模型system/organization/temporarylevel 0/1/2 |
| Token TTL | access 15min / refresh 7dayrefresh 用 Redis 黑名单失效 | | 视口配置 | 4 层模型(导航/路由/组件/数据),`iam_role_viewports` 表含 `level` ENUM(admin/teacher/student/parent) |
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 | | DataScope 解析 | 6 级self/subject/class/grade/school/allSUBJECT 替代 DISTRICT注入 JWT payload |
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers | | 权限校验 | PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存 TTL 5min`data_scope=all` 直放行Key `iam:perm:{userId}` |
| ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 | | Token 黑名单 | Redis Key `iam:bl:{jti}` TTL 与 refresh_token 剩余有效期对齐logout/refresh 时写入 |
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()`select 返回字段名按 schema 定义(如 `r.iam_roles` 而非 `r.roles` | | 审计日志 | `iam_user_audit_log` 表 + `AuditCreated` Kafka 事件,登录/角色变更/密码修改等关键操作记录 |
| 健康检查依赖 | `readyz` 用 `db.execute(sql\`SELECT 1\`)` 校验连接,不要依赖 typeorm DataSourceIAM 用 Drizzle无 typeorm | | 家长-学生关系 | `iam_student_guardians` 表unique(studentId, guardianId)`GetChildrenByParent` RPC + `GET /v1/iam/children` REST |
| Gateway 身份传递 | Controller 直接读 `req.headers['x-user-id']` / `x-user-roles`,不要依赖未注册的 AuthMiddleware 的 `AuthenticatedRequest` | | schema 表 | iam_users / iam_roles / iam_user_roles / iam_permissions / iam_role_permissions / iam_refresh_tokens / iam_role_viewports / iam_student_guardians / iam_user_audit_log / iam_password_history |
| DEV_MODE 登录 | DEV_MODE=true 时 Gateway 接受 `Bearer dev-token` 注入固定身份IAM 仍支持真实 JWTHS256P2 应改 RS256 | | Outbox | shared-ts `OutboxModule.forRoot({ config, db, kafkaProducer })`,表名 `iam_outbox`topic `edu.iam.user.events`/`edu.iam.role.events`/`edu.iam.audit.created` |
| P2 公开路径白名单 | Gateway `publicPaths` map 含 `/iam/register`/`/iam/login`/`/iam/refresh`AuthMiddleware 跳过鉴权避免死锁 | | AuthMiddleware 注册 | `app.module.ts` `configure()` 注册于 me/logout/viewports/permissions/roles/audit/children 路由,解析 `x-user-*` 头 |
| P2 视口过滤 | `getUserViewports` 按 `requiredPermission` 过滤 + `sortOrder` 字典序排序,无权限要求的视口全员可见 | | 公开路径 | register/login/refresh/jwks.json 无 `@RequirePermission()`PermissionGuard 旁路 |
| P2 JWT payload | HS256 签名含 `sub/email/roles/dataScope/type`register 自动分配 teacher 角色TEACHER_ROLE_ID 固定 UUID | | ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 |
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()``delete().where()` 不支持链式多次 where用 `and(eq(...), eq(...))` 组合 |
| Drizzle DataScope 类型 | `mysqlEnum` 推断类型为字面量联合Repository 方法参数须用 `DataScope` 类型而非 `string`,否则 TS 报错 |
| ioredis 类型 | `import { Redis } from "ioredis"`named import`type RedisClient = InstanceType<typeof Redis>`,默认导入在 TS 中不可构造 |
| jwt.sign expiresIn 类型 | `@types/jsonwebtoken` v9 `expiresIn` 类型为 `number \| StringValue`,普通 string 不兼容,用 `ttlToSeconds(ttl)` 返回 number |
| 健康检查依赖 | `/readyz` 5 依赖DB(SELECT 1) / Redis(ping) / Kafka(producer 实例) / JWKS(密钥文件可读) / gRPC(进程内) |
| 优雅停机 | 顺序Kafka producer disconnect → Redis quit → DB pool endLifecycleService `onApplicationShutdown` |
### 2.4 core-eduTS/NestJSP3 ### 2.4 core-eduTS/NestJSP3

View File

@@ -2,15 +2,18 @@ syntax = "proto3";
package next_edu_cloud.events.v1; package next_edu_cloud.events.v1;
// Cross-service event contracts published by CoreEdu via the transactional // Cross-service event contracts published via the transactional outbox pattern
// outbox pattern and consumed by downstream services (notifications, analytics, // and consumed by downstream services (notifications, analytics, audit, etc.).
// audit, etc.). Topics follow the convention edu.{domain}.events. // Topics follow the convention edu.<domain>.<aggregate>.<action>.
// //
// Event routing (TOPIC_MAP in outbox.publisher.ts): // Event routing (TOPIC_MAP in outbox publisher):
// edu.exam.events <- exam.created / exam.updated / exam.deleted // edu.exam.events <- exam.created / exam.updated / exam.deleted
// edu.homework.events <- homework.assigned / homework.submitted / homework.graded // edu.homework.events <- homework.assigned / homework.submitted / homework.graded
// edu.grade.events <- grade.recorded / grade.updated // edu.grade.events <- grade.recorded / grade.updated
// edu.class.events <- class.transferred // edu.class.events <- class.transferred
// edu.iam.user.events <- user.created / user.updated / user.disabled / user.role_changed
// edu.iam.role.events <- role.created / role.updated
// edu.iam.audit.created <- audit (unified audit topic, action field distinguishes)
message ClassEvent { message ClassEvent {
string event_id = 1; string event_id = 1;
@@ -58,3 +61,51 @@ message GradeEvent {
string action = 8; string action = 8;
map<string, string> metadata = 9; map<string, string> metadata = 9;
} }
// IAM 用户事件edu.iam.user.events topic
// action: created / updated / disabled / role_changed
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;
}
// IAM 角色事件edu.iam.role.events topic
// action: created / updated / deleted
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;
}
// IAM 审计事件edu.iam.audit.created topic统一审计 topic
// action: create / update / delete / login / logout / permission_change
message AuditEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string actor_user_id = 5;
string action = 6;
string resource_type = 7;
string resource_id = 8;
string before_state = 9;
string after_state = 10;
string ip = 11;
string user_agent = 12;
string trace_id = 13;
map<string, string> metadata = 14;
}

View File

@@ -3,14 +3,33 @@ syntax = "proto3";
package next_edu_cloud.iam.v1; package next_edu_cloud.iam.v1;
// IamService 定义身份与访问管理契约 // IamService 定义身份与访问管理契约
// P2: REST 实现P3 起转 gRPC // 双入口策略president §2.16REST 供 gateway 透传 + admin-portal 直连,
// gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
// gRPC 端口 50052P2 即启用I1 裁决)。
service IamService { service IamService {
// 认证类
rpc Register(RegisterRequest) returns (AuthResponse); rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (AuthResponse); rpc Login(LoginRequest) returns (AuthResponse);
rpc RefreshToken(RefreshTokenRequest) returns (TokenPair); rpc RefreshToken(RefreshTokenRequest) returns (TokenPair);
rpc Logout(LogoutRequest) returns (LogoutResponse);
// 用户信息类
rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo); rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo);
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
// 权限与视口类
rpc GetEffectivePermissions(GetEffectivePermissionsRequest) returns (EffectivePermissionsResponse);
rpc GetEffectiveAccess(GetEffectiveAccessRequest) returns (EffectiveAccessResponse);
rpc GetEffectiveDataScope(GetEffectiveDataScopeRequest) returns (DataScopeResponse);
rpc GetViewports(GetViewportsRequest) returns (ViewportsResponse);
// 密钥与关系类
rpc GetPublicKey(GetPublicKeyRequest) returns (PublicKeyResponse);
rpc GetChildrenByParent(GetChildrenByParentRequest) returns (ChildrenResponse);
} }
// ========== 认证类 ==========
message RegisterRequest { message RegisterRequest {
string email = 1; string email = 1;
string password = 2; string password = 2;
@@ -26,8 +45,13 @@ message RefreshTokenRequest {
string refresh_token = 1; string refresh_token = 1;
} }
message GetUserInfoRequest { message LogoutRequest {
string user_id = 1; string refresh_token = 1;
string user_id = 2;
}
message LogoutResponse {
bool success = 1;
} }
message AuthResponse { message AuthResponse {
@@ -41,10 +65,95 @@ message TokenPair {
int32 expires_in = 3; int32 expires_in = 3;
} }
// ========== 用户信息类 ==========
message GetUserInfoRequest {
string user_id = 1;
}
message BatchGetUsersRequest {
repeated string user_ids = 1;
}
message BatchGetUsersResponse {
repeated UserInfo users = 1;
}
message UserInfo { message UserInfo {
string id = 1; string id = 1;
string email = 2; string email = 2;
string name = 3; string name = 3;
repeated string roles = 4; repeated string roles = 4;
repeated string permissions = 5; repeated string permissions = 5;
string data_scope = 6;
string status = 7;
}
// ========== 权限与视口类 ==========
message GetEffectivePermissionsRequest {
string user_id = 1;
}
message EffectivePermissionsResponse {
repeated string permissions = 1;
}
message GetEffectiveAccessRequest {
string user_id = 1;
string permission = 2;
}
message EffectiveAccessResponse {
bool allowed = 1;
string data_scope = 2;
}
message GetEffectiveDataScopeRequest {
string user_id = 1;
}
message DataScopeResponse {
string data_scope = 1;
}
message GetViewportsRequest {
string user_id = 1;
}
message ViewportsResponse {
repeated ViewportItem viewports = 1;
}
message ViewportItem {
string key = 1;
string label = 2;
string route = 3;
string icon = 4;
string sort_order = 5;
string required_permission = 6;
}
// ========== 密钥与关系类 ==========
message GetPublicKeyRequest {}
message PublicKeyResponse {
string kid = 1;
string alg = 2;
string public_key_pem = 3;
}
message GetChildrenByParentRequest {
string parent_id = 1;
}
message ChildrenResponse {
repeated ChildInfo children = 1;
}
message ChildInfo {
string student_id = 1;
string name = 2;
string relation = 3;
} }

189
pnpm-lock.yaml generated
View File

@@ -82,8 +82,88 @@ importers:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 version: 5.9.3
packages/hooks:
dependencies:
'@edu/ui-components':
specifier: workspace:*
version: link:../ui-components
react:
specifier: ^18.3.0
version: 18.3.1
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.0
version: 18.3.31
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/shared-proto: {} packages/shared-proto: {}
packages/shared-ts:
dependencies:
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.3.1
drizzle-orm:
specifier: ^0.31.0
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
kafkajs:
specifier: ^2.2.0
version: 2.2.4
pino:
specifier: ^9.4.0
version: 9.14.0
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.0
version: 7.8.2
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.0
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/ui-components:
dependencies:
'@edu/ui-tokens':
specifier: workspace:*
version: link:../ui-tokens
react:
specifier: ^18.3.0
version: 18.3.1
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.0
version: 18.3.31
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/ui-tokens: {}
scripts/arch-scan: scripts/arch-scan:
dependencies: dependencies:
better-sqlite3: better-sqlite3:
@@ -107,7 +187,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -204,7 +284,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -271,7 +351,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -345,12 +425,24 @@ importers:
services/iam: services/iam:
dependencies: dependencies:
'@edu/shared-ts':
specifier: workspace:*
version: link:../../packages/shared-ts
'@grpc/grpc-js':
specifier: ^1.12.0
version: 1.14.4
'@grpc/proto-loader':
specifier: ^0.7.0
version: 0.7.15
'@nestjs/common': '@nestjs/common':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices':
specifier: ^10.4.0
version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -372,9 +464,15 @@ importers:
drizzle-orm: drizzle-orm:
specifier: ^0.31.0 specifier: ^0.31.0
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1) version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
ioredis:
specifier: ^5.4.0
version: 5.11.1
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.3 version: 9.0.3
kafkajs:
specifier: ^2.2.0
version: 2.2.4
mysql2: mysql2:
specifier: ^3.11.0 specifier: ^3.11.0
version: 3.22.6(@types/node@22.20.0) version: 3.22.6(@types/node@22.20.0)
@@ -435,7 +533,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -505,7 +603,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': '@nestjs/core':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -1312,6 +1410,11 @@ packages:
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
engines: {node: '>=12.10.0'} engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.7.15':
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
engines: {node: '>=6'}
hasBin: true
'@grpc/proto-loader@0.8.1': '@grpc/proto-loader@0.8.1':
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1422,6 +1525,42 @@ packages:
'@nestjs/websockets': '@nestjs/websockets':
optional: true optional: true
'@nestjs/microservices@10.4.22':
resolution: {integrity: sha512-9Oxc0jQuppGLaQv5yaB2tVS2rAZzZ9NqDS1A4UlDLiYwJB7M6e89G6tmyOQjGjPwgoXPxQS4Vg2voSiKiED2gw==}
peerDependencies:
'@grpc/grpc-js': '*'
'@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0
'@nestjs/websockets': ^10.0.0
amqp-connection-manager: '*'
amqplib: '*'
cache-manager: '*'
ioredis: '*'
kafkajs: '*'
mqtt: '*'
nats: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
'@grpc/grpc-js':
optional: true
'@nestjs/websockets':
optional: true
amqp-connection-manager:
optional: true
amqplib:
optional: true
cache-manager:
optional: true
ioredis:
optional: true
kafkajs:
optional: true
mqtt:
optional: true
nats:
optional: true
'@nestjs/platform-express@10.4.22': '@nestjs/platform-express@10.4.22':
resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==}
peerDependencies: peerDependencies:
@@ -1494,6 +1633,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2503,6 +2646,9 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.1.0 '@opentelemetry/api': ^1.1.0
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -6404,6 +6550,13 @@ snapshots:
'@grpc/proto-loader': 0.8.1 '@grpc/proto-loader': 0.8.1
'@js-sdsl/ordered-map': 4.4.2 '@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.7.15':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.6.5
yargs: 17.7.3
'@grpc/proto-loader@0.8.1': '@grpc/proto-loader@0.8.1':
dependencies: dependencies:
lodash.camelcase: 4.3.0 lodash.camelcase: 4.3.0
@@ -6563,7 +6716,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxtjs/opencollective': 0.3.2 '@nuxtjs/opencollective': 0.3.2
@@ -6575,14 +6728,28 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
uid: 2.0.2 uid: 2.0.2
optionalDependencies: optionalDependencies:
'@nestjs/microservices': 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
'@nestjs/microservices@10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
iterare: 1.2.1
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
'@grpc/grpc-js': 1.14.4
ioredis: 5.11.1
kafkajs: 2.2.4
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
dependencies: dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
body-parser: 1.20.4 body-parser: 1.20.4
cors: 2.8.5 cors: 2.8.5
express: 4.22.1 express: 4.22.1
@@ -6642,6 +6809,8 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33': '@next/swc-win32-x64-msvc@14.2.33':
optional: true optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -8135,6 +8304,10 @@ snapshots:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':

View File

@@ -1,32 +1,57 @@
-- IAM 服务表结构P2 身份阶段 -- IAM 服务表结构P2.1 + P2.2:身份 + RBAC + 审计 + 家长关系
-- 对齐 coord-final-decisions I1-I8 + president §2.15/§2.16/§3.2/§5.5
-- ============================================================
-- 1. iam_users用户主表
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_users` ( CREATE TABLE IF NOT EXISTS `iam_users` (
`id` CHAR(36) NOT NULL, `id` CHAR(36) NOT NULL,
`email` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL, `password_hash` VARCHAR(255) NOT NULL,
`name` VARCHAR(100) NOT NULL, `name` VARCHAR(100) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active', `status` VARCHAR(20) NOT NULL DEFAULT 'active',
`data_scope` ENUM('self','class','grade','school','district','all') NOT NULL DEFAULT 'self', -- DataScope 6 级president §3.2SUBJECT 替代 DISTRICT
`data_scope` ENUM('self','subject','class','grade','school','all') NOT NULL DEFAULT 'self',
-- 密码策略:记录上次修改时间,用于过期校验
`password_changed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `iam_users_email_unique` (`email`) UNIQUE KEY `iam_users_email_unique` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 2. iam_roles角色表三层角色模型
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_roles` ( CREATE TABLE IF NOT EXISTS `iam_roles` (
`id` CHAR(36) NOT NULL, `id` CHAR(36) NOT NULL,
`name` VARCHAR(50) NOT NULL, `name` VARCHAR(50) NOT NULL,
`description` VARCHAR(255) NULL, `description` VARCHAR(255) NULL,
-- 三层角色模型system(系统预设) / organization(组织分配) / temporary(临时授权)
`role_type` ENUM('system','organization','temporary') NOT NULL DEFAULT 'system',
-- 三层优先级system=0(最高) / organization=1 / temporary=2
`level` INT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `iam_roles_name_unique` (`name`) UNIQUE KEY `iam_roles_name_unique` (`name`),
INDEX `idx_iam_roles_type` (`role_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 3. iam_user_roles用户-角色关联
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_user_roles` ( CREATE TABLE IF NOT EXISTS `iam_user_roles` (
`user_id` CHAR(36) NOT NULL, `user_id` CHAR(36) NOT NULL,
`role_id` CHAR(36) NOT NULL, `role_id` CHAR(36) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `role_id`) PRIMARY KEY (`user_id`, `role_id`),
INDEX `idx_iam_user_roles_role` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 4. iam_permissions权限点
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_permissions` ( CREATE TABLE IF NOT EXISTS `iam_permissions` (
`id` CHAR(36) NOT NULL, `id` CHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL, `name` VARCHAR(100) NOT NULL,
@@ -36,24 +61,37 @@ CREATE TABLE IF NOT EXISTS `iam_permissions` (
UNIQUE KEY `iam_permissions_name_unique` (`name`) UNIQUE KEY `iam_permissions_name_unique` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 5. iam_role_permissions角色-权限关联
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_role_permissions` ( CREATE TABLE IF NOT EXISTS `iam_role_permissions` (
`role_id` CHAR(36) NOT NULL, `role_id` CHAR(36) NOT NULL,
`permission_id` CHAR(36) NOT NULL, `permission_id` CHAR(36) NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`) PRIMARY KEY (`role_id`, `permission_id`),
INDEX `idx_iam_role_permissions_perm` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 6. iam_refresh_tokens刷新令牌含 jti 用于黑名单追踪)
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_refresh_tokens` ( CREATE TABLE IF NOT EXISTS `iam_refresh_tokens` (
`id` CHAR(36) NOT NULL, `id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL, `user_id` CHAR(36) NOT NULL,
`token_hash` VARCHAR(255) NOT NULL, `token_hash` VARCHAR(255) NOT NULL,
-- JWT ID用于黑名单追踪I7JWT 黑名单)
`jti` VARCHAR(36) NULL,
`expires_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP NOT NULL,
`revoked_at` TIMESTAMP NULL, `revoked_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
INDEX `idx_iam_refresh_tokens_user_id` (`user_id`) INDEX `idx_iam_refresh_tokens_user` (`user_id`),
INDEX `idx_iam_refresh_tokens_jti` (`jti`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据) -- ============================================================
-- 7. iam_role_viewports视口配置表
-- 4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_role_viewports` ( CREATE TABLE IF NOT EXISTS `iam_role_viewports` (
`id` CHAR(36) NOT NULL, `id` CHAR(36) NOT NULL,
`role_id` CHAR(36) NOT NULL, `role_id` CHAR(36) NOT NULL,
@@ -63,53 +101,161 @@ CREATE TABLE IF NOT EXISTS `iam_role_viewports` (
`icon` VARCHAR(50) NULL, `icon` VARCHAR(50) NULL,
`sort_order` VARCHAR(10) NOT NULL DEFAULT '0', `sort_order` VARCHAR(10) NOT NULL DEFAULT '0',
`required_permission` VARCHAR(100) NULL, `required_permission` VARCHAR(100) NULL,
-- 视口层级admin / teacher / student / parent
`level` ENUM('admin','teacher','student','parent') NOT NULL DEFAULT 'teacher',
-- L3 组件级配置JSON控制组件内按钮/操作的显隐
`component_config` TEXT NULL, `component_config` TEXT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
INDEX `idx_iam_role_viewports_role_id` (`role_id`) INDEX `idx_iam_role_viewports_role` (`role_id`),
INDEX `idx_iam_role_viewports_level` (`level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 种子数据:默认角色 -- ============================================================
INSERT IGNORE INTO `iam_roles` (`id`, `name`, `description`) VALUES -- 8. iam_student_guardians学生-家长关系表I6 裁决)
('00000000-0000-0000-0000-000000000001', 'teacher', '教师角色'), -- ============================================================
('00000000-0000-0000-0000-000000000002', 'admin', '管理员角色'); CREATE TABLE IF NOT EXISTS `iam_student_guardians` (
`id` CHAR(36) NOT NULL,
`student_id` CHAR(36) NOT NULL,
`guardian_id` CHAR(36) NOT NULL,
`relation` VARCHAR(20) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_student_guardian` (`student_id`, `guardian_id`),
INDEX `idx_iam_student_guardians_guardian` (`guardian_id`),
INDEX `idx_iam_student_guardians_student` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 9. iam_user_audit_log审计日志表president §5.5:归 iam
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_user_audit_log` (
`id` CHAR(36) NOT NULL,
`actor_user_id` CHAR(36) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`resource_type` VARCHAR(50) NOT NULL,
`resource_id` VARCHAR(36) NOT NULL,
`before_state` TEXT NULL,
`after_state` TEXT NULL,
`ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`trace_id` VARCHAR(64) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_audit_actor` (`actor_user_id`),
INDEX `idx_iam_audit_resource` (`resource_type`, `resource_id`),
INDEX `idx_iam_audit_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 10. iam_password_history密码历史表密码重用限制
-- ============================================================
CREATE TABLE IF NOT EXISTS `iam_password_history` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_password_history_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 种子数据4 个系统角色(三层角色模型 system 层)
-- ============================================================
INSERT IGNORE INTO `iam_roles` (`id`, `name`, `description`, `role_type`, `level`) VALUES
('00000000-0000-0000-0000-000000000001', 'admin', '系统管理员', 'system', 0),
('00000000-0000-0000-0000-000000000002', 'teacher', '教师', 'system', 0),
('00000000-0000-0000-0000-000000000003', 'student', '学生', 'system', 0),
('00000000-0000-0000-0000-000000000004', 'parent', '家长', 'system', 0);
-- ============================================================
-- 种子数据:权限点(固定 UUID 便于 role_permissions 引用) -- 种子数据:权限点(固定 UUID 便于 role_permissions 引用)
-- ============================================================
INSERT IGNORE INTO `iam_permissions` (`id`, `name`, `resource`, `action`) VALUES INSERT IGNORE INTO `iam_permissions` (`id`, `name`, `resource`, `action`) VALUES
('00000000-0000-0000-0000-000000000101', 'classes:read', 'classes', 'read'), ('00000000-0000-0000-0000-000000000101', 'classes:read', 'classes', 'read'),
('00000000-0000-0000-0000-000000000102', 'classes:create', 'classes', 'create'), ('00000000-0000-0000-0000-000000000102', 'classes:create', 'classes', 'create'),
('00000000-0000-0000-0000-000000000103', 'classes:update', 'classes', 'update'), ('00000000-0000-0000-0000-000000000103', 'classes:update', 'classes', 'update'),
('00000000-0000-0000-0000-000000000104', 'classes:delete', 'classes', 'delete'), ('00000000-0000-0000-0000-000000000104', 'classes:delete', 'classes', 'delete'),
('00000000-0000-0000-0000-000000000201', 'iam:user:read', 'iam', 'user:read'), ('00000000-0000-0000-0000-000000000201', 'iam:user:read', 'iam', 'user:read'),
('00000000-0000-0000-0000-000000000202', 'iam:user:manage', 'iam', 'user:manage'), ('00000000-0000-0000-0000-000000000202', 'iam:user:manage', 'iam', 'user:manage'),
('00000000-0000-0000-0000-000000000203', 'iam:role:manage', 'iam', 'role:manage'); ('00000000-0000-0000-0000-000000000203', 'iam:role:manage', 'iam', 'role:manage'),
('00000000-0000-0000-0000-000000000204', 'iam:audit:read', 'iam', 'audit:read'),
('00000000-0000-0000-0000-000000000205', 'iam:viewport:read', 'iam', 'viewport:read'),
('00000000-0000-0000-0000-000000000301', 'exam:read', 'exam', 'read'),
('00000000-0000-0000-0000-000000000302', 'exam:manage', 'exam', 'manage');
-- 种子数据teacher 角色权限classes CRUD + user:read -- ============================================================
-- 种子数据admin 角色权限(全部权限)
-- ============================================================
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000101'), ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000102'), ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000103'), ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000104'), ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000201'); ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000201'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000202'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000203'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000204'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000205'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000301'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000302');
-- 种子数据admin 角色权限(全部权限) -- ============================================================
-- 种子数据teacher 角色权限classes CRUD + user:read + exam:read
-- ============================================================
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000101'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000102'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000103'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000104'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000201'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000201'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000202'), ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000301');
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000203');
-- 种子数据teacher 角色视口L1 导航) -- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES -- 种子数据student 角色权限classes:read + exam:read
('00000000-0000-0000-0000-000000000a01', '00000000-0000-0000-0000-000000000001', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL), -- ============================================================
('00000000-0000-0000-0000-000000000a02', '00000000-0000-0000-0000-000000000001', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'), INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000a03', '00000000-0000-0000-0000-000000000001', 'profile', '个人中心', '/profile', 'user', '9', NULL); ('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000301');
-- 种子数据admin 角色视口L1 导航) -- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES -- 种子数据parent 角色权限classes:read + viewport:read
('00000000-0000-0000-0000-000000000b01', '00000000-0000-0000-0000-000000000002', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL), -- ============================================================
('00000000-0000-0000-0000-000000000b02', '00000000-0000-0000-0000-000000000002', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'), INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000b03', '00000000-0000-0000-0000-000000000002', 'iam', '用户管理', '/iam/users', 'shield', '2', 'iam:user:read'), ('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000b04', '00000000-0000-0000-0000-000000000002', 'profile', '个人中心', '/profile', 'user', '9', NULL); ('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000205');
-- ============================================================
-- 种子数据admin 视口L1 导航level=admin
-- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`, `level`, `component_config`) VALUES
('00000000-0000-0000-0000-000000000b01', '00000000-0000-0000-0000-000000000001', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL, 'admin', NULL),
('00000000-0000-0000-0000-000000000b02', '00000000-0000-0000-0000-000000000001', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read', 'admin', NULL),
('00000000-0000-0000-0000-000000000b03', '00000000-0000-0000-0000-000000000001', 'iam', '用户管理', '/iam/users', 'shield', '2', 'iam:user:read', 'admin', NULL),
('00000000-0000-0000-0000-000000000b04', '00000000-0000-0000-0000-000000000001', 'audit', '审计日志', '/iam/audit', 'list', '3', 'iam:audit:read', 'admin', NULL),
('00000000-0000-0000-0000-000000000b05', '00000000-0000-0000-0000-000000000001', 'profile', '个人中心', '/profile', 'user', '9', NULL, 'admin', NULL);
-- ============================================================
-- 种子数据teacher 视口L1 导航level=teacher
-- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`, `level`, `component_config`) VALUES
('00000000-0000-0000-0000-000000000a01', '00000000-0000-0000-0000-000000000002', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL, 'teacher', NULL),
('00000000-0000-0000-0000-000000000a02', '00000000-0000-0000-0000-000000000002', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read', 'teacher', NULL),
('00000000-0000-0000-0000-000000000a03', '00000000-0000-0000-0000-000000000002', 'exam', '考试管理', '/exam', 'file-text','2', 'exam:read', 'teacher', NULL),
('00000000-0000-0000-0000-000000000a04', '00000000-0000-0000-0000-000000000002', 'profile', '个人中心', '/profile', 'user', '9', NULL, 'teacher', NULL);
-- ============================================================
-- 种子数据student 视口L1 导航level=student
-- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`, `level`, `component_config`) VALUES
('00000000-0000-0000-0000-000000000c01', '00000000-0000-0000-0000-000000000003', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL, 'student', NULL),
('00000000-0000-0000-0000-000000000c02', '00000000-0000-0000-0000-000000000003', 'classes', '我的班级', '/classes', 'users', '1', 'classes:read', 'student', NULL),
('00000000-0000-0000-0000-000000000c03', '00000000-0000-0000-0000-000000000003', 'exam', '我的考试', '/exam', 'file-text','2', 'exam:read', 'student', NULL),
('00000000-0000-0000-0000-000000000c04', '00000000-0000-0000-0000-000000000003', 'profile', '个人中心', '/profile', 'user', '9', NULL, 'student', NULL);
-- ============================================================
-- 种子数据parent 视口L1 导航level=parent
-- ============================================================
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`, `level`, `component_config`) VALUES
('00000000-0000-0000-0000-000000000d01', '00000000-0000-0000-0000-000000000004', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL, 'parent', NULL),
('00000000-0000-0000-0000-000000000d02', '00000000-0000-0000-0000-000000000004', 'children', '我的孩子', '/children', 'users', '1', 'classes:read', 'parent', NULL),
('00000000-0000-0000-0000-000000000d03', '00000000-0000-0000-0000-000000000004', 'profile', '个人中心', '/profile', 'user', '9', NULL, 'parent', NULL);

View File

@@ -1,44 +1,135 @@
# IAM Service # IAM Service
身份与访问管理服务Identity and Access ManagementP2 身份阶段交付 身份与访问管理服务Identity and Access Management
> 版本v0.2.0(对齐 coord-final-decisions I1-I8 + president §2.15/§2.16/§3.2/§5.5
> 详细设计见 [02-architecture-design.md](./docs/02-architecture-design.md)
## 职责 ## 职责
- 用户注册 / 登录 / 刷新令牌 - 用户注册 / 登录 / 刷新令牌 / 登出
- 角色Role与权限Permission管理 - JWT RS256 签发access 15min + refresh 7dayjti 黑名单)
- Access / Refresh Token 签发与校验 - 角色Role与权限Permission管理三层角色模型system/organization/temporary
- 用户信息查询(供 BFF / Gateway 聚合 - DataScope 6 级数据范围self/subject/class/grade/school/all
- 视口Viewport配置查询
- 学生-家长关系管理(`GetChildrenByParent`
- 审计日志(`iam_user_audit_log` + `AuditCreated` 事件)
- JWKS 公钥暴露(供 api-gateway 验签)
- 双入口REST `/v1/iam/*` + gRPC 50052package `next_edu_cloud.iam.v1`12 RPC
## 技术栈 ## 技术栈
- NestJS 10 + TypeScriptESM - NestJS 10 + TypeScriptESMNodeNext
- Drizzle ORM + MySQL - Drizzle ORM + MySQL10 张表)
- bcrypt 密码哈希 - bcrypt 密码哈希cost ≥ 12
- jsonwebtokenP2 骨架使用 HS256后续切 RS256 - jsonwebtoken RS256本地文件密钥
- ioredis权限缓存 TTL 5min + token jti 黑名单)
- kafkajs + shared-ts OutboxModule事务性事件发布
- @nestjs/microservices + @grpc/grpc-jsgRPC server
- pino 日志 / prom-client 指标 / OpenTelemetry 链路 - pino 日志 / prom-client 指标 / OpenTelemetry 链路
## 端口 ## 端口
默认 `3002`,通过 `PORT` 环境变量覆盖。 | 服务 | 端口 | 备注 |
| -------- | ----- | ------------------------ |
| iam HTTP | 3002 | REST 入口(`PORT` |
| iam gRPC | 50052 | gRPC 入口(`GRPC_PORT` |
## API ## 环境变量
| Method | Path | 说明 | | 变量 | 说明 | 默认值 |
| ------ | --------------- | --------------------------------- | | ----------------------------- | ------------------------------ | ---------------- |
| POST | `/iam/register` | 注册 | | `PORT` | HTTP 端口 | `3002` |
| POST | `/iam/login` | 登录 | | `GRPC_PORT` | gRPC 端口 | `50052` |
| POST | `/iam/refresh` | 刷新令牌 | | `DATABASE_URL` | MySQL 连接串 | — |
| GET | `/iam/me` | 当前用户信息(需 `x-user-id` 头) | | `REDIS_URL` | Redis 连接串 | — |
| `IAM_PRIVATE_KEY_PATH` | RS256 私钥文件路径 | — |
| `IAM_PUBLIC_KEY_PATH` | RS256 公钥文件路径 | — |
| `JWT_ISSUER` | JWT 签发者 | `next-edu-cloud` |
| `JWT_AUDIENCE` | JWT 受众 | `next-edu-cloud` |
| `JWT_KEY_ID` | 密钥 IDkid用于 JWKS 轮换) | `iam-rs256-v1` |
| `ACCESS_TOKEN_TTL` | access_token TTL | `15m` |
| `REFRESH_TOKEN_TTL_DAYS` | refresh_token TTL | `7` |
| `KAFKA_BROKERS` | Kafka broker 列表(逗号分隔) | — |
| `KAFKA_CLIENT_ID` | Kafka client ID | `iam-service` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel OTLP 端点(可选) | — |
| `LOG_LEVEL` | 日志级别 | `info` |
| `NODE_ENV` | 环境标识 | `development` |
## REST API
| Method | Path | 权限 | 说明 |
| ------ | ------------------------------- | ----------------- | ---------------------------------- |
| POST | `/v1/iam/register` | 公开 | 注册 |
| POST | `/v1/iam/login` | 公开 | 登录 |
| POST | `/v1/iam/refresh` | 公开 | 刷新令牌(轮换 + 旧 jti 加黑名单) |
| POST | `/v1/iam/logout` | `IAM_USER_READ` | 登出 |
| 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` | 审计日志 |
| GET | `/v1/iam/.well-known/jwks.json` | 公开 | RS256 公钥 JWK Set |
## gRPC API
12 RPCproto 定义见 `packages/shared-proto/proto/iam.proto`package `next_edu_cloud.iam.v1`
`Register` / `Login` / `RefreshToken` / `Logout` / `GetUserInfo` / `BatchGetUsers` / `GetEffectivePermissions` / `GetEffectiveAccess` / `GetEffectiveDataScope` / `GetViewports` / `GetPublicKey` / `GetChildrenByParent`
## 健康检查 ## 健康检查
| 端点 | 用途 | 鉴权 | | 端点 | 用途 | 鉴权 |
| -------------- | ------------------------------------------------- | ---- | | -------------- | ------------------------------------------------------------------------- | ---- |
| `GET /healthz` | 存活探针liveness仅返回进程状态,不检查依赖 | 无 | | `GET /healthz` | 存活探针liveness仅返回进程状态 | 无 |
| `GET /readyz` | 就绪探针readiness检查 DB 连接,失败返回 503 | 无 | | `GET /readyz` | 就绪探针readiness检查 DB/Redis/Kafka/JWKS/gRPC 5 依赖,失败返回 503 | 无 |
实现见 `src/shared/health/health.controller.ts`5 个 NestJS 服务iam/core-edu/content/msg/classes一致。 ## 数据表10 张)
## 数据表 | 表名 | 用途 |
| ----------------------- | --------------------------- |
| `iam_users` | 用户主表 |
| `iam_roles` | 角色表role_type + level |
| `iam_user_roles` | 用户-角色绑定 |
| `iam_permissions` | 权限点表 |
| `iam_role_permissions` | 角色-权限映射 |
| `iam_refresh_tokens` | refresh token含 jti |
| `iam_role_viewports` | 角色-视口配置(含 level |
| `iam_student_guardians` | 学生-家长关系 |
| `iam_user_audit_log` | 审计日志 |
| `iam_password_history` | 密码重用限制 |
`iam_users` / `iam_roles` / `iam_user_roles` / `iam_permissions` / `iam_role_permissions` / `iam_refresh_tokens` DDL + 种子数据见 `scripts/iam-init.sql`
## Kafka 事件
| Topic | 事件 |
| ----------------------- | -------------- |
| `edu.iam.user.events` | `UserCreated` |
| `edu.iam.role.events` | `RoleChanged` |
| `edu.iam.audit.created` | `AuditCreated` |
通过 shared-ts `OutboxModule` 事务性发布at-least-once + idempotent producer
## 开发
```bash
# 安装依赖
pnpm install
# 初始化数据库(需先启动 MySQL + Redis + Kafka
mysql -u root -p < scripts/iam-init.sql
# 生成 RS256 密钥对
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# 启动开发服务
pnpm --filter @edu/iam-service dev
# 质量校验
pnpm --filter @edu/iam-service lint
pnpm --filter @edu/iam-service typecheck
```

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@edu/iam-service", "name": "@edu/iam-service",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,23 +12,29 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@edu/shared-ts": "workspace:*",
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.0",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",
"@nestjs/microservices": "^10.4.0",
"@nestjs/platform-express": "^10.4.0", "@nestjs/platform-express": "^10.4.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"bcrypt": "^5.1.0",
"drizzle-orm": "^0.31.0", "drizzle-orm": "^0.31.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.0",
"kafkajs": "^2.2.0",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"prom-client": "^15.1.0", "prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0",
"uuid": "^10.0.0",
"bcrypt": "^5.1.0",
"jsonwebtoken": "^9.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0" "rxjs": "^7.8.0",
"uuid": "^10.0.0",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",

View File

@@ -1,15 +1,85 @@
import { Module } from "@nestjs/common"; import {
Module,
NestModule,
MiddlewareConsumer,
OnModuleInit,
Logger,
} from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core"; import { APP_GUARD } from "@nestjs/core";
import { IamModule } from "./iam/iam.module.js"; import { IamModule } from "./iam/iam.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js"; import { PermissionGuard } from "./middleware/permission.guard.js";
import { AuthMiddleware } from "./middleware/auth.middleware.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
import { OutboxModule } from "@edu/shared-ts/outbox";
import { getDbInstance } from "./config/database.js";
import {
getKafkaProducer,
connectKafkaProducer,
IAM_KAFKA_TOPICS,
} from "./config/kafka.js";
/**
* IAM 根模块。
*
* 装配:
* - IamModule业务
* - HealthModule健康检查
* - OutboxModule事务性事件发布I5 裁决)
* - AuthMiddleware从 Gateway 注入的 x-user-* 头部解析用户身份)
* - PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存I3 裁决)
*/
@Module({ @Module({
imports: [IamModule, HealthModule], imports: [
IamModule,
HealthModule,
OutboxModule.forRoot({
config: {
tableName: "iam_outbox",
kafkaTopic: IAM_KAFKA_TOPICS.USER_EVENTS,
pollIntervalMs: 1000,
batchSize: 20,
maxRetryCount: 5,
retryBackoffMs: 1000,
},
db: getDbInstance(),
kafkaProducer: getKafkaProducer(),
}),
],
providers: [ providers: [
{ provide: APP_GUARD, useClass: PermissionGuard }, { provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService, LifecycleService,
], ],
}) })
export class AppModule {} export class AppModule implements NestModule, OnModuleInit {
private readonly logger = new Logger(AppModule.name);
async onModuleInit(): Promise<void> {
// 连接 Kafka producerOutbox 投递前置依赖)
try {
await connectKafkaProducer();
this.logger.log("Kafka producer connected");
} catch (error) {
this.logger.error(
`Kafka producer connect failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
configure(consumer: MiddlewareConsumer): void {
// AuthMiddleware 应用于需要鉴权的 /v1/iam 路由
// 公开端点register/login/refresh/jwks/health/metrics不走此中间件
consumer
.apply(AuthMiddleware)
.forRoutes(
"v1/iam/me",
"v1/iam/logout",
"v1/iam/viewports",
"v1/iam/permissions/effective",
"v1/iam/children",
"v1/iam/roles",
"v1/iam/permissions",
"v1/iam/audit",
);
}
}

View File

@@ -17,9 +17,23 @@ export function getDb(): MySql2Database {
return drizzle(pool); return drizzle(pool);
} }
/**
* 全局 db 实例(模块装配时初始化,供 OutboxModule 等需要 db 引用的模块使用)。
* 在 AppModule.onModuleInit 中通过 ensureDbInitialized() 确保已创建。
*/
let dbInstance: MySql2Database | null = null;
export function getDbInstance(): MySql2Database {
if (!dbInstance) {
dbInstance = getDb();
}
return dbInstance;
}
export async function closeDb(): Promise<void> { export async function closeDb(): Promise<void> {
if (pool) { if (pool) {
await pool.end(); await pool.end();
pool = null; pool = null;
dbInstance = null;
} }
} }

View File

@@ -1,15 +1,39 @@
import { z } from 'zod'; import { z } from "zod";
const envSchema = z.object({ const envSchema = z.object({
PORT: z.string().default('3002'), // HTTP
PORT: z.string().default("3002"),
// Database
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string(), // Redis缓存 + token 黑名单)
JWT_ISSUER: z.string().default('next-edu-cloud'), REDIS_URL: z.string().url(),
JWT_AUDIENCE: z.string().default('next-edu-cloud'),
// JWT RS256president §2.15:本地文件密钥)
IAM_PRIVATE_KEY_PATH: z.string(),
IAM_PUBLIC_KEY_PATH: z.string(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
JWT_AUDIENCE: z.string().default("next-edu-cloud"),
JWT_KEY_ID: z.string().default("iam-rs256-v1"),
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
// KafkaOutbox 投递)
KAFKA_BROKERS: z.string(),
KAFKA_CLIENT_ID: z.string().default("iam-service"),
// gRPC serverI1 裁决:端口 50052
GRPC_PORT: z.string().default("50052"),
// 可观测性
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), LOG_LEVEL: z
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), .enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;
@@ -17,8 +41,11 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env { export function loadEnv(): Env {
const result = envSchema.safeParse(process.env); const result = envSchema.safeParse(process.env);
if (!result.success) { if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors); console.error(
throw new Error('Invalid environment configuration'); "❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");
} }
return result.data; return result.data;
} }

View File

@@ -0,0 +1,54 @@
import { readFileSync } from "node:fs";
import { env } from "./env.js";
/**
* JWT RS256 密钥对加载president §2.15:本地文件密钥)。
*
* - 私钥IAM 签发 access_token / refresh_token
* - 公钥api-gateway 通过 JWKS 或 gRPC GetPublicKey 拉取验签
*
* 启动时一次性加载到内存,避免每次签名/验签的 IO 开销。
* kidKey ID用于 JWKS 端点多密钥轮换场景下标识密钥。
*/
export interface JwtKeyPair {
privateKey: string;
publicKey: string;
kid: string;
alg: "RS256";
}
let keyPair: JwtKeyPair | null = null;
export function getJwtKeyPair(): JwtKeyPair {
if (!keyPair) {
const privateKey = readFileSync(env.IAM_PRIVATE_KEY_PATH, "utf-8");
const publicKey = readFileSync(env.IAM_PUBLIC_KEY_PATH, "utf-8");
keyPair = {
privateKey,
publicKey,
kid: env.JWT_KEY_ID,
alg: "RS256",
};
}
return keyPair;
}
/**
* TTL 计算:将 "15m" / "7d" 等字符串转为秒数。
* 用于 JWT expiresIn 配置与响应中的 expires_in 字段。
*/
export function ttlToSeconds(ttl: string): number {
const match = /^(\d+)([smhd])$/.exec(ttl);
if (!match || match[1] === undefined || match[2] === undefined) {
throw new Error(`Invalid TTL format: ${ttl}`);
}
const value = Number.parseInt(match[1], 10);
const unit = match[2] as "s" | "m" | "h" | "d";
const multipliers: Record<"s" | "m" | "h" | "d", number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
};
return value * multipliers[unit];
}

View File

@@ -0,0 +1,44 @@
import { Kafka, type Producer } from "kafkajs";
import { env } from "./env.js";
let producer: Producer | null = null;
export function getKafkaProducer(): Producer {
if (!producer) {
const kafka = new Kafka({
clientId: env.KAFKA_CLIENT_ID,
brokers: env.KAFKA_BROKERS.split(","),
});
producer = kafka.producer({
idempotent: true,
transactionalId: "iam-tx",
});
}
return producer;
}
export async function connectKafkaProducer(): Promise<void> {
const p = getKafkaProducer();
await p.connect();
}
export async function disconnectKafkaProducer(): Promise<void> {
if (producer) {
await producer.disconnect();
producer = null;
}
}
/**
* IAM Kafka topic 路由coord-final-decisions I5 + iam_contract §1.4)。
*
* 事件命名规则:`<Aggregate>.<Action>`
* - UserEvent: created/updated/disabled/role_changed
* - RoleEvent: created/updated
* - AuditEvent: create/update/delete/login/logout/permission_change
*/
export const IAM_KAFKA_TOPICS = {
USER_EVENTS: "edu.iam.user.events",
ROLE_EVENTS: "edu.iam.role.events",
AUDIT_CREATED: "edu.iam.audit.created",
} as const;

View File

@@ -0,0 +1,24 @@
import { Redis } from "ioredis";
import { env } from "./env.js";
type RedisClient = InstanceType<typeof Redis>;
let client: RedisClient | null = null;
export function getRedis(): RedisClient {
if (!client) {
client = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
}
return client;
}
export async function closeRedis(): Promise<void> {
if (client) {
await client.quit();
client = null;
}
}

View File

@@ -0,0 +1,40 @@
import { Controller, Get, Query, Req } from "@nestjs/common";
import { IamRepository } from "./iam.repository.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
/**
* 审计日志查询端点president §5.5:审计日志归 iam
*
* 路径前缀:/v1/iam
* 端点GET /v1/iam/audit
*/
@Controller("v1/iam")
export class AuditController {
constructor(private readonly repository: IamRepository) {}
@Get("audit")
@RequirePermission(Permissions.IAM_AUDIT_READ)
async queryAudit(
@Query("actorUserId") actorUserId: string | undefined,
@Query("resourceType") resourceType: string | undefined,
@Query("resourceId") resourceId: string | undefined,
@Query("limit") limitStr: string | undefined,
@Query("offset") offsetStr: string | undefined,
@Req() _req: AuthenticatedRequest,
): Promise<{ success: true; data: unknown[] }> {
const limit = limitStr ? Number.parseInt(limitStr, 10) : 50;
const offset = offsetStr ? Number.parseInt(offsetStr, 10) : 0;
const data = await this.repository.queryAuditLog({
actorUserId,
resourceType,
resourceId,
limit: Number.isNaN(limit) ? 50 : limit,
offset: Number.isNaN(offset) ? 0 : offset,
});
return { success: true as const, data };
}
}

View File

@@ -1,19 +1,37 @@
import { Body, Controller, Get, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { TokenPair, UserInfo } from "./iam.service.js"; import type {
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js"; TokenPair,
UserInfo,
ViewportItem,
ChildInfo,
} from "./iam.service.js";
import {
registerSchema,
loginSchema,
refreshTokenSchema,
logoutSchema,
} from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js"; import { UnauthorizedError } from "../shared/errors/application-error.js";
import { import {
Permissions, Permissions,
RequirePermission, RequirePermission,
} from "../middleware/permission.guard.js"; } from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
@Controller("iam") /**
* IAM REST Controller双入口之 REST 侧)。
*
* 路径前缀:/v1/iamI7 裁决REST 路径统一加 /v1 前缀)
* gateway 路由:/iam/v1/* → iam /v1/iam/*(透传不改路径)
*
* 公开端点register / login / refresh / jwksJwksController
* 鉴权端点me / viewports / permissions/effective / children / logout
*/
@Controller("v1/iam")
export class IamController { export class IamController {
constructor(private readonly service: IamService) {} constructor(private readonly service: IamService) {}
// 公开端点:注册,不设权限校验
@Post("register") @Post("register")
async register( async register(
@Body() body: unknown, @Body() body: unknown,
@@ -23,7 +41,6 @@ export class IamController {
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:登录,不设权限校验
@Post("login") @Post("login")
async login( async login(
@Body() body: unknown, @Body() body: unknown,
@@ -33,7 +50,6 @@ export class IamController {
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:刷新令牌,不设权限校验
@Post("refresh") @Post("refresh")
async refresh( async refresh(
@Body() body: unknown, @Body() body: unknown,
@@ -43,15 +59,70 @@ export class IamController {
return { success: true as const, data: tokens }; return { success: true as const, data: tokens };
} }
@Post("logout")
@RequirePermission(Permissions.IAM_USER_READ)
async logout(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: boolean } }> {
const dto = logoutSchema.parse(body);
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
await this.service.logout(dto.refreshToken, userId);
return { success: true as const, data: { success: true } };
}
@Get("me") @Get("me")
@RequirePermission(Permissions.IAM_USER_READ) @RequirePermission(Permissions.IAM_USER_READ)
async me(@Req() req: Request): Promise<{ success: true; data: UserInfo }> { async me(
const userIdHeader = req.headers["x-user-id"]; @Req() req: AuthenticatedRequest,
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined; ): Promise<{ success: true; data: UserInfo }> {
const userId = req.userId;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing user identity");
} }
const user = await this.service.getUserInfo(userId); const user = await this.service.getUserInfo(userId);
return { success: true as const, data: user }; return { success: true as const, data: user };
} }
@Get("viewports")
@RequirePermission(Permissions.IAM_USER_READ)
async viewports(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: ViewportItem[] }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const data = await this.service.getViewports(userId);
return { success: true as const, data };
}
@Get("permissions/effective")
@RequirePermission(Permissions.IAM_USER_READ)
async effectivePermissions(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const permissions = await this.service.getEffectivePermissions(userId);
return { success: true as const, data: { permissions } };
}
@Get("children")
@RequirePermission(Permissions.IAM_USER_READ)
async children(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: ChildInfo[] }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const data = await this.service.getChildrenByParent(userId);
return { success: true as const, data };
}
} }

View File

@@ -1,4 +1,4 @@
import { z } from 'zod'; import { z } from "zod";
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -15,5 +15,10 @@ export const refreshTokenSchema = z.object({
refreshToken: z.string(), refreshToken: z.string(),
}); });
export const logoutSchema = z.object({
refreshToken: z.string(),
});
export type RegisterDto = z.infer<typeof registerSchema>; export type RegisterDto = z.infer<typeof registerSchema>;
export type LoginDto = z.infer<typeof loginSchema>; export type LoginDto = z.infer<typeof loginSchema>;
export type LogoutDto = z.infer<typeof logoutSchema>;

View File

@@ -0,0 +1,159 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { IamService } from "./iam.service.js";
/**
* IAM gRPC Controller双入口之 gRPC 侧I1 裁决)。
*
* 端口 50052供 BFF 聚合调用teacher-bff / student-bff / parent-bff
* 同一 IamService 实例同时被 REST Controller 和本 gRPC Controller 调用,
* 业务逻辑不重复president §2.16 双入口策略)。
*
* proto package: next_edu_cloud.iam.v1
* service name: IamService
*/
@Controller()
export class IamGrpcController {
constructor(private readonly service: IamService) {}
@GrpcMethod("IamService", "Register")
async register(data: {
email: string;
password: string;
name: string;
}): Promise<unknown> {
const result = await this.service.register(data);
return {
user: this.toUserInfoProto(result.user),
tokens: this.toTokenPairProto(result.tokens),
};
}
@GrpcMethod("IamService", "Login")
async login(data: { email: string; password: string }): Promise<unknown> {
const result = await this.service.login(data);
return {
user: this.toUserInfoProto(result.user),
tokens: this.toTokenPairProto(result.tokens),
};
}
@GrpcMethod("IamService", "RefreshToken")
async refreshToken(data: { refreshToken: string }): Promise<unknown> {
const tokens = await this.service.refresh(data.refreshToken);
return this.toTokenPairProto(tokens);
}
@GrpcMethod("IamService", "Logout")
async logout(data: {
refreshToken: string;
userId: string;
}): Promise<{ success: boolean }> {
await this.service.logout(data.refreshToken, data.userId);
return { success: true };
}
@GrpcMethod("IamService", "GetUserInfo")
async getUserInfo(data: { userId: string }): Promise<unknown> {
const user = await this.service.getUserInfo(data.userId);
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "BatchGetUsers")
async batchGetUsers(data: { userIds: string[] }): Promise<unknown> {
const users = await this.service.batchGetUsers(data.userIds ?? []);
return { users: users.map((u) => this.toUserInfoProto(u)) };
}
@GrpcMethod("IamService", "GetEffectivePermissions")
async getEffectivePermissions(data: {
userId: string;
}): Promise<{ permissions: string[] }> {
const permissions = await this.service.getEffectivePermissions(data.userId);
return { permissions };
}
@GrpcMethod("IamService", "GetEffectiveAccess")
async getEffectiveAccess(data: {
userId: string;
permission: string;
}): Promise<{ allowed: boolean; dataScope: string }> {
return this.service.getEffectiveAccess(data.userId, data.permission);
}
@GrpcMethod("IamService", "GetEffectiveDataScope")
async getEffectiveDataScope(data: {
userId: string;
}): Promise<{ dataScope: string }> {
const dataScope = await this.service.getEffectiveDataScope(data.userId);
return { dataScope };
}
@GrpcMethod("IamService", "GetViewports")
async getViewports(data: { userId: string }): Promise<unknown> {
const viewports = await this.service.getViewports(data.userId);
return {
viewports: viewports.map((vp) => ({
key: vp.key,
label: vp.label,
route: vp.route,
icon: vp.icon ?? "",
sortOrder: vp.sortOrder,
requiredPermission: vp.requiredPermission ?? "",
})),
};
}
@GrpcMethod("IamService", "GetPublicKey")
async getPublicKey(): Promise<{
kid: string;
alg: string;
publicKeyPem: string;
}> {
return this.service.getPublicKey();
}
@GrpcMethod("IamService", "GetChildrenByParent")
async getChildrenByParent(data: { parentId: string }): Promise<unknown> {
const children = await this.service.getChildrenByParent(data.parentId);
return {
children: children.map((c) => ({
studentId: c.studentId,
name: c.name,
relation: c.relation,
})),
};
}
private toUserInfoProto(user: {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
status: string;
}): Record<string, unknown> {
return {
id: user.id,
email: user.email,
name: user.name,
roles: user.roles,
permissions: user.permissions,
dataScope: user.dataScope,
status: user.status,
};
}
private toTokenPairProto(tokens: {
accessToken: string;
refreshToken: string;
expiresIn: number;
}): Record<string, unknown> {
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
};
}
}

View File

@@ -1,11 +1,24 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { IamController } from "./iam.controller.js"; import { IamController } from "./iam.controller.js";
import { RbacController } from "./rbac.controller.js"; import { RbacController } from "./rbac.controller.js";
import { AuditController } from "./audit.controller.js";
import { JwksController } from "./jwks.controller.js";
import { IamGrpcController } from "./iam.grpc.controller.js";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import { IamRepository } from "./iam.repository.js"; import { IamRepository } from "./iam.repository.js";
import { JwksService } from "./jwks.service.js";
import { PermissionCacheService } from "../shared/cache/permission-cache.service.js";
import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.js";
@Module({ @Module({
controllers: [IamController, RbacController], controllers: [IamController, RbacController, AuditController, JwksController],
providers: [IamService, IamRepository], providers: [
IamService,
IamRepository,
JwksService,
PermissionCacheService,
TokenBlacklistService,
IamGrpcController,
],
}) })
export class IamModule {} export class IamModule {}

View File

@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray, and } from "drizzle-orm";
import { getDb } from "../config/database.js"; import { getDb } from "../config/database.js";
import { import {
users, users,
@@ -8,16 +8,36 @@ import {
rolePermissions, rolePermissions,
refreshTokens, refreshTokens,
roleViewports, roleViewports,
studentGuardians,
userAuditLog,
passwordHistory,
} from "./iam.schema.js";
import type {
User,
Role,
Permission,
RoleViewport,
StudentGuardian,
AuditLog,
DataScope,
} from "./iam.schema.js"; } from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js"; import { DatabaseError } from "../shared/errors/application-error.js";
/**
* IAM 数据访问层。
*
* 覆盖:用户 CRUD、角色/权限查询、刷新令牌管理、视口查询、
* 学生-家长关系、审计日志、密码历史。
*/
export class IamRepository { export class IamRepository {
// ============ 用户 ============
async createUser(data: { async createUser(data: {
id: string; id: string;
email: string; email: string;
passwordHash: string; passwordHash: string;
name: string; name: string;
dataScope?: DataScope;
}): Promise<User> { }): Promise<User> {
const db = getDb(); const db = getDb();
await db.insert(users).values(data); await db.insert(users).values(data);
@@ -43,6 +63,27 @@ export class IamRepository {
return result; return result;
} }
async batchFindUsers(ids: string[]): Promise<User[]> {
if (ids.length === 0) return [];
const db = getDb();
return db.select().from(users).where(inArray(users.id, ids));
}
async updateUserStatus(userId: string, status: string): Promise<void> {
const db = getDb();
await db.update(users).set({ status }).where(eq(users.id, userId));
}
async updatePassword(userId: string, passwordHash: string): Promise<void> {
const db = getDb();
await db
.update(users)
.set({ passwordHash, passwordChangedAt: new Date() })
.where(eq(users.id, userId));
}
// ============ 角色 ============
async getUserRoles(userId: string): Promise<Role[]> { async getUserRoles(userId: string): Promise<Role[]> {
const db = getDb(); const db = getDb();
const result = await db const result = await db
@@ -53,6 +94,31 @@ export class IamRepository {
return result.map((r) => r.iam_roles); return result.map((r) => r.iam_roles);
} }
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async findRoleByName(name: string): Promise<Role | undefined> {
const db = getDb();
const [result] = await db.select().from(roles).where(eq(roles.name, name));
return result;
}
async assignRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db.insert(userRoles).values({ userId, roleId });
}
async revokeRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)));
}
// ============ 权限 ============
async getUserPermissions(userId: string): Promise<Permission[]> { async getUserPermissions(userId: string): Promise<Permission[]> {
const db = getDb(); const db = getDb();
const userRoleRows = await db const userRoleRows = await db
@@ -72,6 +138,13 @@ export class IamRepository {
return result.map((r) => r.iam_permissions); return result.map((r) => r.iam_permissions);
} }
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
// ============ 视口 ============
async getUserViewports(userId: string): Promise<RoleViewport[]> { async getUserViewports(userId: string): Promise<RoleViewport[]> {
const db = getDb(); const db = getDb();
const userRoleRows = await db const userRoleRows = await db
@@ -86,40 +159,43 @@ export class IamRepository {
.where(inArray(roleViewports.roleId, roleIds)); .where(inArray(roleViewports.roleId, roleIds));
} }
async getUserDataScope(userId: string): Promise<string> { // ============ 刷新令牌 ============
const db = getDb();
const [user] = await db
.select({ dataScope: users.dataScope })
.from(users)
.where(eq(users.id, userId));
return user?.dataScope ?? "self";
}
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
async assignRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db.insert(userRoles).values({ userId, roleId });
}
async createRefreshToken(data: { async createRefreshToken(data: {
id: string; id: string;
userId: string; userId: string;
tokenHash: string; tokenHash: string;
jti?: string;
expiresAt: Date; expiresAt: Date;
}): Promise<void> { }): Promise<void> {
const db = getDb(); const db = getDb();
await db.insert(refreshTokens).values(data); await db.insert(refreshTokens).values(data);
} }
async findRefreshTokenByHash(tokenHash: string): Promise<
| {
id: string;
userId: string;
jti: string | null;
expiresAt: Date;
revokedAt: Date | null;
}
| undefined
> {
const db = getDb();
const [result] = await db
.select({
id: refreshTokens.id,
userId: refreshTokens.userId,
jti: refreshTokens.jti,
expiresAt: refreshTokens.expiresAt,
revokedAt: refreshTokens.revokedAt,
})
.from(refreshTokens)
.where(eq(refreshTokens.tokenHash, tokenHash));
return result;
}
async revokeRefreshToken(id: string): Promise<void> { async revokeRefreshToken(id: string): Promise<void> {
const db = getDb(); const db = getDb();
await db await db
@@ -127,4 +203,116 @@ export class IamRepository {
.set({ revokedAt: new Date() }) .set({ revokedAt: new Date() })
.where(eq(refreshTokens.id, id)); .where(eq(refreshTokens.id, id));
} }
async revokeAllUserTokens(userId: string): Promise<void> {
const db = getDb();
await db
.update(refreshTokens)
.set({ revokedAt: new Date() })
.where(eq(refreshTokens.userId, userId));
}
// ============ 学生-家长关系I6 裁决) ============
async getChildrenByGuardian(guardianId: string): Promise<
Array<{
studentId: string;
studentName: string;
relation: string;
}>
> {
const db = getDb();
const result = await db
.select({
studentId: studentGuardians.studentId,
studentName: users.name,
relation: studentGuardians.relation,
})
.from(studentGuardians)
.innerJoin(users, eq(studentGuardians.studentId, users.id))
.where(eq(studentGuardians.guardianId, guardianId));
return result;
}
async createStudentGuardianRelation(data: {
id: string;
studentId: string;
guardianId: string;
relation: string;
}): Promise<StudentGuardian> {
const db = getDb();
await db.insert(studentGuardians).values(data);
const [result] = await db
.select()
.from(studentGuardians)
.where(eq(studentGuardians.id, data.id));
if (!result) {
throw new DatabaseError("Failed to create student-guardian relation");
}
return result;
}
// ============ 审计日志president §5.5 ============
async createAuditLog(data: {
id: string;
actorUserId: string;
action: string;
resourceType: string;
resourceId: string;
beforeState?: string | null;
afterState?: string | null;
ip?: string | null;
userAgent?: string | null;
traceId?: string | null;
}): Promise<void> {
const db = getDb();
await db.insert(userAuditLog).values(data);
}
async queryAuditLog(options: {
actorUserId?: string;
resourceType?: string;
resourceId?: string;
limit?: number;
offset?: number;
}): Promise<AuditLog[]> {
const db = getDb();
let query = db.select().from(userAuditLog).$dynamic();
if (options.actorUserId) {
query = query.where(eq(userAuditLog.actorUserId, options.actorUserId));
}
if (options.resourceType) {
query = query.where(eq(userAuditLog.resourceType, options.resourceType));
}
if (options.resourceId) {
query = query.where(eq(userAuditLog.resourceId, options.resourceId));
}
const limit = options.limit ?? 50;
const offset = options.offset ?? 0;
return query.limit(limit).offset(offset);
}
// ============ 密码历史 ============
async getPasswordHistory(userId: string, limit = 5): Promise<string[]> {
const db = getDb();
const rows = await db
.select({ passwordHash: passwordHistory.passwordHash })
.from(passwordHistory)
.where(eq(passwordHistory.userId, userId))
.limit(limit);
return rows.map((r) => r.passwordHash);
}
async addPasswordHistory(
userId: string,
passwordHash: string,
): Promise<void> {
const db = getDb();
const id = crypto.randomUUID();
await db.insert(passwordHistory).values({ id, userId, passwordHash });
}
} }

View File

@@ -4,40 +4,79 @@ import {
char, char,
timestamp, timestamp,
text, text,
int,
mysqlEnum, mysqlEnum,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core"; } from "drizzle-orm/mysql-core";
// DataScope 6 级president §3.2SUBJECT 替代 DISTRICT
export const DATA_SCOPES = [
"self",
"subject",
"class",
"grade",
"school",
"all",
] as const;
export type DataScope = (typeof DATA_SCOPES)[number];
// 三层角色模型P2.2system / organization / temporary
export const ROLE_TYPES = ["system", "organization", "temporary"] as const;
export type RoleType = (typeof ROLE_TYPES)[number];
// 视口 4 层admin / teacher / student / parent
export const VIEWPORT_LEVELS = [
"admin",
"teacher",
"student",
"parent",
] as const;
export type ViewportLevel = (typeof VIEWPORT_LEVELS)[number];
export const users = mysqlTable("iam_users", { export const users = mysqlTable("iam_users", {
id: char("id", { length: 36 }).notNull().primaryKey(), id: char("id", { length: 36 }).notNull().primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(), email: varchar("email", { length: 255 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(), passwordHash: varchar("password_hash", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }).notNull(), name: varchar("name", { length: 100 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("active"), status: varchar("status", { length: 20 }).notNull().default("active"),
dataScope: mysqlEnum("data_scope", [ dataScope: mysqlEnum("data_scope", DATA_SCOPES).notNull().default("self"),
"self", // 密码策略:记录上次修改时间,用于过期校验
"class", passwordChangedAt: timestamp("password_changed_at").notNull().defaultNow(),
"grade",
"school",
"district",
"all",
])
.notNull()
.default("self"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
}); });
export const roles = mysqlTable("iam_roles", { export const roles = mysqlTable(
id: char("id", { length: 36 }).notNull().primaryKey(), "iam_roles",
name: varchar("name", { length: 50 }).notNull().unique(), {
description: varchar("description", { length: 255 }), id: char("id", { length: 36 }).notNull().primaryKey(),
}); name: varchar("name", { length: 50 }).notNull().unique(),
description: varchar("description", { length: 255 }),
// 三层角色模型system(系统预设) / organization(组织分配) / temporary(临时授权)
roleType: mysqlEnum("role_type", ROLE_TYPES).notNull().default("system"),
// 三层优先级system=0(最高) / organization=1 / temporary=2
level: int("level").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
roleTypeIdx: index("idx_iam_roles_type").on(table.roleType),
}),
);
export const userRoles = mysqlTable("iam_user_roles", { export const userRoles = mysqlTable(
userId: char("user_id", { length: 36 }).notNull(), "iam_user_roles",
roleId: char("role_id", { length: 36 }).notNull(), {
createdAt: timestamp("created_at").notNull().defaultNow(), userId: char("user_id", { length: 36 }).notNull(),
}); roleId: char("role_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
pk: index("idx_iam_user_roles_pk").on(table.userId, table.roleId),
roleIdx: index("idx_iam_user_roles_role").on(table.roleId),
}),
);
export const permissions = mysqlTable("iam_permissions", { export const permissions = mysqlTable("iam_permissions", {
id: char("id", { length: 36 }).notNull().primaryKey(), id: char("id", { length: 36 }).notNull().primaryKey(),
@@ -46,36 +85,128 @@ export const permissions = mysqlTable("iam_permissions", {
action: varchar("action", { length: 50 }).notNull(), action: varchar("action", { length: 50 }).notNull(),
}); });
export const rolePermissions = mysqlTable("iam_role_permissions", { export const rolePermissions = mysqlTable(
roleId: char("role_id", { length: 36 }).notNull(), "iam_role_permissions",
permissionId: char("permission_id", { length: 36 }).notNull(), {
}); roleId: char("role_id", { length: 36 }).notNull(),
permissionId: char("permission_id", { length: 36 }).notNull(),
},
(table) => ({
pk: index("idx_iam_role_permissions_pk").on(
table.roleId,
table.permissionId,
),
permIdx: index("idx_iam_role_permissions_perm").on(table.permissionId),
}),
);
export const refreshTokens = mysqlTable("iam_refresh_tokens", { export const refreshTokens = mysqlTable(
id: char("id", { length: 36 }).notNull().primaryKey(), "iam_refresh_tokens",
userId: char("user_id", { length: 36 }).notNull(), {
tokenHash: varchar("token_hash", { length: 255 }).notNull(), id: char("id", { length: 36 }).notNull().primaryKey(),
expiresAt: timestamp("expires_at").notNull(), userId: char("user_id", { length: 36 }).notNull(),
revokedAt: timestamp("revoked_at"), tokenHash: varchar("token_hash", { length: 255 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(), // JWT ID用于黑名单追踪
}); jti: varchar("jti", { length: 36 }),
expiresAt: timestamp("expires_at").notNull(),
revokedAt: timestamp("revoked_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
userIdx: index("idx_iam_refresh_tokens_user").on(table.userId),
jtiIdx: index("idx_iam_refresh_tokens_jti").on(table.jti),
}),
);
// 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据) // 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据)
// 每条记录代表一个角色可见的导航项 export const roleViewports = mysqlTable(
export const roleViewports = mysqlTable("iam_role_viewports", { "iam_role_viewports",
id: char("id", { length: 36 }).notNull().primaryKey(), {
roleId: char("role_id", { length: 36 }).notNull(), id: char("id", { length: 36 }).notNull().primaryKey(),
viewportKey: varchar("viewport_key", { length: 50 }).notNull(), roleId: char("role_id", { length: 36 }).notNull(),
label: varchar("label", { length: 100 }).notNull(), viewportKey: varchar("viewport_key", { length: 50 }).notNull(),
route: varchar("route", { length: 200 }).notNull(), label: varchar("label", { length: 100 }).notNull(),
icon: varchar("icon", { length: 50 }), route: varchar("route", { length: 200 }).notNull(),
sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"), icon: varchar("icon", { length: 50 }),
requiredPermission: varchar("required_permission", { length: 100 }), sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"),
// L3 组件级配置JSON控制组件内按钮/操作的显隐 requiredPermission: varchar("required_permission", { length: 100 }),
componentConfig: text("component_config"), // 视口层级admin / teacher / student / parent
}); level: mysqlEnum("level", VIEWPORT_LEVELS).notNull().default("teacher"),
// L3 组件级配置JSON控制组件内按钮/操作的显隐
componentConfig: text("component_config"),
},
(table) => ({
roleIdx: index("idx_iam_role_viewports_role").on(table.roleId),
levelIdx: index("idx_iam_role_viewports_level").on(table.level),
}),
);
// 学生-家长关系表I6 裁决:表名 iam_student_guardians
export const studentGuardians = mysqlTable(
"iam_student_guardians",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
studentId: char("student_id", { length: 36 }).notNull(),
guardianId: char("guardian_id", { length: 36 }).notNull(),
relation: varchar("relation", { length: 20 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
guardianStudentUniq: uniqueIndex("uniq_student_guardian").on(
table.studentId,
table.guardianId,
),
guardianIdx: index("idx_iam_student_guardians_guardian").on(
table.guardianId,
),
studentIdx: index("idx_iam_student_guardians_student").on(table.studentId),
}),
);
// 审计日志表president §5.5:审计日志归 iam
export const userAuditLog = mysqlTable(
"iam_user_audit_log",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
actorUserId: char("actor_user_id", { length: 36 }).notNull(),
action: varchar("action", { length: 50 }).notNull(),
resourceType: varchar("resource_type", { length: 50 }).notNull(),
resourceId: varchar("resource_id", { length: 36 }).notNull(),
beforeState: text("before_state"),
afterState: text("after_state"),
ip: varchar("ip", { length: 45 }),
userAgent: varchar("user_agent", { length: 255 }),
traceId: varchar("trace_id", { length: 64 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
actorIdx: index("idx_iam_audit_actor").on(table.actorUserId),
resourceIdx: index("idx_iam_audit_resource").on(
table.resourceType,
table.resourceId,
),
createdAtIdx: index("idx_iam_audit_created").on(table.createdAt),
}),
);
// 密码历史表(密码重用限制)
export const passwordHistory = mysqlTable(
"iam_password_history",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
userId: char("user_id", { length: 36 }).notNull(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
userIdx: index("idx_iam_password_history_user").on(table.userId),
}),
);
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
export type Role = typeof roles.$inferSelect; export type Role = typeof roles.$inferSelect;
export type Permission = typeof permissions.$inferSelect; export type Permission = typeof permissions.$inferSelect;
export type RoleViewport = typeof roleViewports.$inferSelect; export type RoleViewport = typeof roleViewports.$inferSelect;
export type StudentGuardian = typeof studentGuardians.$inferSelect;
export type AuditLog = typeof userAuditLog.$inferSelect;
export type PasswordHistoryEntry = typeof passwordHistory.$inferSelect;

View File

@@ -1,16 +1,23 @@
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { Inject } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { IamRepository } from "./iam.repository.js"; import { IamRepository } from "./iam.repository.js";
import { JwksService } from "./jwks.service.js";
import { import {
ConflictError, ConflictError,
UnauthorizedError, UnauthorizedError,
NotFoundError, NotFoundError,
} from "../shared/errors/application-error.js"; } from "../shared/errors/application-error.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { getJwtKeyPair, ttlToSeconds } from "../config/jwt.js";
import { OutboxService } from "@edu/shared-ts/outbox";
import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.js";
import { PermissionCacheService } from "../shared/cache/permission-cache.service.js";
import type { RegisterDto, LoginDto } from "./iam.dto.js"; import type { RegisterDto, LoginDto } from "./iam.dto.js";
import type { User, Role, Permission } from "./iam.schema.js"; import type { User } from "./iam.schema.js";
// 默认角色(种子数据 role_id
const DEFAULT_ROLE_ID = "00000000-0000-0000-0000-000000000002"; // teacher
export interface TokenPair { export interface TokenPair {
accessToken: string; accessToken: string;
@@ -25,6 +32,7 @@ export interface UserInfo {
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
dataScope: string; dataScope: string;
status: string;
} }
export interface ViewportItem { export interface ViewportItem {
@@ -36,14 +44,39 @@ export interface ViewportItem {
requiredPermission: string | null; requiredPermission: string | null;
} }
// teacher 角色固定 ID种子数据 export interface ChildInfo {
const TEACHER_ROLE_ID = "00000000-0000-0000-0000-000000000001"; studentId: string;
name: string;
relation: string;
}
/**
* IAM Application Service双入口REST Controller + gRPC Controller 共用)。
*
* 12 RPC 方法对齐 iam.proto
* Register / Login / RefreshToken / Logout /
* GetUserInfo / BatchGetUsers /
* GetEffectivePermissions / GetEffectiveAccess / GetEffectiveDataScope / GetViewports /
* GetPublicKey / GetChildrenByParent
*
* 集成:
* - RS256 签发president §2.15
* - Outbox 事件发布I5UserEvent / RoleEvent / AuditEvent
* - 审计日志president §5.5
* - Redis 权限缓存 + token 黑名单I3 / I7
*/
@Injectable()
export class IamService { export class IamService {
constructor( constructor(
@Inject(IamRepository) private readonly repository: IamRepository, @Inject(IamRepository) private readonly repository: IamRepository,
private readonly jwksService: JwksService,
private readonly outbox: OutboxService,
private readonly tokenBlacklist: TokenBlacklistService,
private readonly permissionCache: PermissionCacheService,
) {} ) {}
// ============ 认证类 ============
async register( async register(
dto: RegisterDto, dto: RegisterDto,
): Promise<{ user: UserInfo; tokens: TokenPair }> { ): Promise<{ user: UserInfo; tokens: TokenPair }> {
@@ -52,7 +85,7 @@ export class IamService {
throw new ConflictError("Email already registered"); throw new ConflictError("Email already registered");
} }
const userId = uuidv4(); const userId = crypto.randomUUID();
const passwordHash = await bcrypt.hash(dto.password, 12); const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.repository.createUser({ const user = await this.repository.createUser({
id: userId, id: userId,
@@ -61,10 +94,36 @@ export class IamService {
name: dto.name, name: dto.name,
}); });
// 默认分配 teacher 角色 await this.repository.assignRole(userId, DEFAULT_ROLE_ID);
await this.repository.assignRole(userId, TEACHER_ROLE_ID);
const { tokens } = await this.issueTokens(user); const { tokens } = await this.issueTokens(user);
// Outbox: UserEvent created
await this.outbox.publish(
"UserCreated",
{
event_id: crypto.randomUUID(),
aggregate_id: userId,
event_type: "UserCreated",
occurred_at: Date.now(),
user_id: userId,
email: dto.email,
name: dto.name,
roles: ["teacher"],
data_scope: "self",
action: "created",
metadata: {},
},
{ aggregateId: userId },
);
// 审计日志
await this.writeAuditLog(userId, "create", "user", userId, null, {
id: userId,
email: dto.email,
name: dto.name,
});
const info = await this.buildUserInfo(user); const info = await this.buildUserInfo(user);
return { user: info, tokens }; return { user: info, tokens };
} }
@@ -85,14 +144,23 @@ export class IamService {
} }
const { tokens } = await this.issueTokens(user); const { tokens } = await this.issueTokens(user);
// 审计日志
await this.writeAuditLog(user.id, "login", "user", user.id, null, null);
const info = await this.buildUserInfo(user); const info = await this.buildUserInfo(user);
return { user: info, tokens }; return { user: info, tokens };
} }
async refresh(refreshToken: string): Promise<TokenPair> { async refresh(refreshToken: string): Promise<TokenPair> {
const keyPair = getJwtKeyPair();
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
const decoded = jwt.verify(refreshToken, env.JWT_SECRET); const decoded = jwt.verify(refreshToken, keyPair.publicKey, {
algorithms: ["RS256"],
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
});
if (typeof decoded === "string") { if (typeof decoded === "string") {
throw new UnauthorizedError("Invalid refresh token"); throw new UnauthorizedError("Invalid refresh token");
} }
@@ -106,18 +174,59 @@ export class IamService {
} }
const sub = typeof payload.sub === "string" ? payload.sub : undefined; const sub = typeof payload.sub === "string" ? payload.sub : undefined;
const jti = typeof payload.jti === "string" ? payload.jti : undefined;
if (!sub) { if (!sub) {
throw new UnauthorizedError("Invalid token subject"); throw new UnauthorizedError("Invalid token subject");
} }
// 检查黑名单
if (jti) {
const blacklisted = await this.tokenBlacklist.isBlacklisted(jti);
if (blacklisted) {
throw new UnauthorizedError("Token has been revoked");
}
}
const user = await this.repository.findUserById(sub); const user = await this.repository.findUserById(sub);
if (!user) { if (!user) {
throw new NotFoundError("User", sub); throw new NotFoundError("User", sub);
} }
// 旧 token 加入黑名单(轮换)
if (jti && payload.exp) {
const ttl = payload.exp - Math.floor(Date.now() / 1000);
await this.tokenBlacklist.blacklist(jti, ttl);
}
return this.issueTokens(user).then((r) => r.tokens); return this.issueTokens(user).then((r) => r.tokens);
} }
async logout(refreshToken: string, userId: string): Promise<void> {
const keyPair = getJwtKeyPair();
try {
const decoded = jwt.verify(refreshToken, keyPair.publicKey, {
algorithms: ["RS256"],
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
});
const payload = typeof decoded === "string" ? null : decoded;
const jti = payload?.jti;
const exp = payload?.exp;
if (jti && exp) {
const ttl = exp - Math.floor(Date.now() / 1000);
await this.tokenBlacklist.blacklist(jti, ttl);
}
} catch {
// token 无效也视为已登出,不抛错
}
await this.repository.revokeAllUserTokens(userId);
await this.writeAuditLog(userId, "logout", "user", userId, null, null);
}
// ============ 用户信息类 ============
async getUserInfo(userId: string): Promise<UserInfo> { async getUserInfo(userId: string): Promise<UserInfo> {
const user = await this.repository.findUserById(userId); const user = await this.repository.findUserById(userId);
if (!user) { if (!user) {
@@ -126,18 +235,50 @@ export class IamService {
return this.buildUserInfo(user); return this.buildUserInfo(user);
} }
// 有效权限聚合:多角色权限去重 async batchGetUsers(userIds: string[]): Promise<UserInfo[]> {
async getEffectivePermissions(userId: string): Promise<string[]> { const users = await this.repository.batchFindUsers(userIds);
const perms = await this.repository.getUserPermissions(userId); return Promise.all(users.map((u) => this.buildUserInfo(u)));
const unique = new Set(perms.map((p) => p.name));
return [...unique];
} }
async getUserViewports(userId: string): Promise<ViewportItem[]> { // ============ 权限与视口类 ============
async getEffectivePermissions(userId: string): Promise<string[]> {
// 先查缓存
const cached = await this.permissionCache.getPermissions(userId);
if (cached) return cached;
const perms = await this.repository.getUserPermissions(userId);
const unique = [...new Set(perms.map((p) => p.name))];
await this.permissionCache.setPermissions(userId, unique);
return unique;
}
async getEffectiveAccess(
userId: string,
permission: string,
): Promise<{ allowed: boolean; dataScope: string }> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError("User", userId);
}
const perms = await this.getEffectivePermissions(userId);
const allowed = user.dataScope === "all" || perms.includes(permission);
return { allowed, dataScope: user.dataScope };
}
async getEffectiveDataScope(userId: string): Promise<string> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError("User", userId);
}
return user.dataScope;
}
async getViewports(userId: string): Promise<ViewportItem[]> {
const viewports = await this.repository.getUserViewports(userId); const viewports = await this.repository.getUserViewports(userId);
const permissions = await this.getEffectivePermissions(userId); const permissions = await this.getEffectivePermissions(userId);
// 过滤:如果视口需要权限且用户不具备,则不返回
return viewports return viewports
.filter((vp) => { .filter((vp) => {
if (!vp.requiredPermission) return true; if (!vp.requiredPermission) return true;
@@ -154,18 +295,42 @@ export class IamService {
.sort((a, b) => a.sortOrder.localeCompare(b.sortOrder)); .sort((a, b) => a.sortOrder.localeCompare(b.sortOrder));
} }
async getAllRoles(): Promise<Role[]> { // ============ 密钥与关系类 ============
getPublicKey(): { kid: string; alg: string; publicKeyPem: string } {
return this.jwksService.getPublicKeyPem();
}
async getChildrenByParent(parentId: string): Promise<ChildInfo[]> {
const children = await this.repository.getChildrenByGuardian(parentId);
return children.map((c) => ({
studentId: c.studentId,
name: c.studentName,
relation: c.relation,
}));
}
// ============ 管理端查询 ============
async getAllRoles() {
return this.repository.getAllRoles(); return this.repository.getAllRoles();
} }
async getAllPermissions(): Promise<Permission[]> { async getAllPermissions() {
return this.repository.getAllPermissions(); return this.repository.getAllPermissions();
} }
// ============ 私有方法 ============
private async issueTokens(user: User): Promise<{ tokens: TokenPair }> { private async issueTokens(user: User): Promise<{ tokens: TokenPair }> {
const keyPair = getJwtKeyPair();
const roles = await this.repository.getUserRoles(user.id); const roles = await this.repository.getUserRoles(user.id);
const roleNames = roles.map((r) => r.name); const roleNames = roles.map((r) => r.name);
const dataScope = user.dataScope; const dataScope = user.dataScope;
const jti = crypto.randomUUID();
const accessTtl = env.ACCESS_TOKEN_TTL;
const refreshTtlDays = Number.parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10);
const refreshTtlSeconds = refreshTtlDays * 86400;
const accessToken = jwt.sign( const accessToken = jwt.sign(
{ {
@@ -175,30 +340,43 @@ export class IamService {
dataScope, dataScope,
type: "access", type: "access",
}, },
env.JWT_SECRET, keyPair.privateKey,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "15m" }, {
algorithm: "RS256",
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
expiresIn: ttlToSeconds(accessTtl),
keyid: keyPair.kid,
},
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ sub: user.id, type: "refresh" }, { sub: user.id, type: "refresh", jti },
env.JWT_SECRET, keyPair.privateKey,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "7d" }, {
algorithm: "RS256",
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
expiresIn: refreshTtlSeconds,
keyid: keyPair.kid,
},
); );
// 存储 refresh token hash // 存储 refresh token hash + jti
const tokenHash = await bcrypt.hash(refreshToken, 10); const tokenHash = await bcrypt.hash(refreshToken, 10);
await this.repository.createRefreshToken({ await this.repository.createRefreshToken({
id: uuidv4(), id: crypto.randomUUID(),
userId: user.id, userId: user.id,
tokenHash, tokenHash,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), jti,
expiresAt: new Date(Date.now() + refreshTtlSeconds * 1000),
}); });
return { return {
tokens: { tokens: {
accessToken, accessToken,
refreshToken, refreshToken,
expiresIn: 15 * 60, expiresIn: ttlToSeconds(accessTtl),
}, },
}; };
} }
@@ -213,6 +391,51 @@ export class IamService {
roles: roles.map((r) => r.name), roles: roles.map((r) => r.name),
permissions: permissions.map((p) => p.name), permissions: permissions.map((p) => p.name),
dataScope: user.dataScope, dataScope: user.dataScope,
status: user.status,
}; };
} }
private async writeAuditLog(
actorUserId: string,
action: string,
resourceType: string,
resourceId: string,
beforeState: unknown,
afterState: unknown,
): Promise<void> {
await this.repository.createAuditLog({
id: crypto.randomUUID(),
actorUserId,
action,
resourceType,
resourceId,
beforeState: beforeState ? JSON.stringify(beforeState) : null,
afterState: afterState ? JSON.stringify(afterState) : null,
ip: null,
userAgent: null,
traceId: null,
});
// Outbox: AuditEvent
await this.outbox.publish(
"AuditCreated",
{
event_id: crypto.randomUUID(),
aggregate_id: resourceId,
event_type: "AuditCreated",
occurred_at: Date.now(),
actor_user_id: actorUserId,
action,
resource_type: resourceType,
resource_id: resourceId,
before_state: beforeState ? JSON.stringify(beforeState) : "",
after_state: afterState ? JSON.stringify(afterState) : "",
ip: "",
user_agent: "",
trace_id: "",
metadata: {},
},
{ aggregateId: resourceId },
);
}
} }

View File

@@ -0,0 +1,21 @@
import { Controller, Get } from "@nestjs/common";
import { JwksService } from "./jwks.service.js";
import type { JwksResponse } from "./jwks.service.js";
/**
* JWKS 端点president §2.15 + I7
*
* 路径GET /v1/iam/.well-known/jwks.json
* 公开端点无需鉴权Gateway 启动时拉取并缓存公钥用于验签)。
*
* gateway 路由:/iam/v1/.well-known/jwks.json → iam /v1/iam/.well-known/jwks.json
*/
@Controller("v1/iam")
export class JwksController {
constructor(private readonly jwksService: JwksService) {}
@Get(".well-known/jwks.json")
jwks(): JwksResponse {
return this.jwksService.getJwks();
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from "@nestjs/common";
import { createPublicKey } from "node:crypto";
import { getJwtKeyPair } from "../config/jwt.js";
/**
* JWK Set 项RFC 7517 / RFC 7518 RS256
*/
export interface Jwk {
kty: "RSA";
use: "sig";
alg: "RS256";
kid: string;
n: string; // modulus base64url
e: string; // exponent base64url
}
export interface JwksResponse {
keys: Jwk[];
}
/**
* JWKS 服务president §2.15 + I7 裁决)。
*
* 将 RS256 公钥 PEM 转换为 JWK 格式,供 api-gateway 拉取验签。
* 端点GET /iam/v1/.well-known/jwks.json
*
* 使用 Node.js 内置 crypto 模块的 export({ format: 'jwk' })
* 无需额外依赖。
*/
@Injectable()
export class JwksService {
/**
* 返回 JWK Set。当前仅单密钥结构预留多密钥轮换能力。
*/
getJwks(): JwksResponse {
const keyPair = getJwtKeyPair();
const publicKeyObj = createPublicKey(keyPair.publicKey);
const jwk = publicKeyObj.export({ format: "jwk" }) as Record<
string,
unknown
>;
// Node export 返回 { kty, n, e },补全 use/alg/kid
return {
keys: [
{
kty: jwk.kty as "RSA",
use: "sig",
alg: "RS256",
kid: keyPair.kid,
n: jwk.n as string,
e: jwk.e as string,
},
],
};
}
/**
* 返回公钥 PEM供 gRPC GetPublicKey RPC 使用)。
*/
getPublicKeyPem(): { kid: string; alg: string; publicKeyPem: string } {
const keyPair = getJwtKeyPair();
return {
kid: keyPair.kid,
alg: keyPair.alg,
publicKeyPem: keyPair.publicKey,
};
}
}

View File

@@ -1,61 +1,29 @@
import { Controller, Get, Req } from "@nestjs/common"; import { Controller, Get } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { ViewportItem } from "./iam.service.js";
import type { Role, Permission } from "./iam.schema.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import { import {
Permissions, Permissions,
RequirePermission, RequirePermission,
} from "../middleware/permission.guard.js"; } from "../middleware/permission.guard.js";
// RBAC 管理端点:角色/权限/视口查询 /**
@Controller("iam") * RBAC 管理端点:角色/权限查询admin-portal 使用)。
*
* 路径前缀:/v1/iamI7 裁决)
*/
@Controller("v1/iam")
export class RbacController { export class RbacController {
constructor(private readonly service: IamService) {} constructor(private readonly service: IamService) {}
// 获取当前用户的视口配置L1 导航)
@Get("viewports")
@RequirePermission(Permissions.IAM_USER_READ)
async viewports(
@Req() req: Request,
): Promise<{ success: true; data: ViewportItem[] }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const data = await this.service.getUserViewports(userId);
return { success: true as const, data };
}
// 获取当前用户的有效权限
@Get("permissions/effective")
@RequirePermission(Permissions.IAM_USER_READ)
async effectivePermissions(
@Req() req: Request,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const permissions = await this.service.getEffectivePermissions(userId);
return { success: true as const, data: { permissions } };
}
// 列出所有角色(管理端用)
@Get("roles") @Get("roles")
@RequirePermission(Permissions.IAM_ROLE_MANAGE) @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async roles(): Promise<{ success: true; data: Role[] }> { async roles(): Promise<{ success: true; data: unknown[] }> {
const data = await this.service.getAllRoles(); const data = await this.service.getAllRoles();
return { success: true as const, data }; return { success: true as const, data };
} }
// 列出所有权限点(管理端用)
@Get("permissions") @Get("permissions")
@RequirePermission(Permissions.IAM_ROLE_MANAGE) @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async permissions(): Promise<{ success: true; data: Permission[] }> { async permissions(): Promise<{ success: true; data: unknown[] }> {
const data = await this.service.getAllPermissions(); const data = await this.service.getAllPermissions();
return { success: true as const, data }; return { success: true as const, data };
} }

View File

@@ -1,16 +1,36 @@
import "reflect-metadata"; import "reflect-metadata";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
import { AppModule } from "./app.module.js"; import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js"; import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import { getJwtKeyPair } from "./config/jwt.js";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
/**
* IAM 服务启动入口。
*
* 双入口president §2.16
* - HTTP serverenv.PORT3002供 gateway 透传 + admin-portal 直连
* - gRPC serverenv.GRPC_PORT50052供 BFF 聚合调用I1 裁决)
*
* 启动顺序:
* 1. initTracerOTel SDK
* 2. 创建 NestApplication
* 3. 注册 GlobalErrorFilter
* 4. 启动 gRPC microservicehybrid app
* 5. 启动 HTTP server
* 6. 预加载 JWT 密钥对(确保文件可读)
*/
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
// 预加载 JWT 密钥对(启动时即校验文件可读,避免运行时才发现配置错误)
getJwtKeyPair();
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: ["log", "error", "warn"], logger: ["log", "error", "warn"],
}); });
@@ -18,15 +38,30 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter()); app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // gRPC microservice端口 50052I1 裁决)
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
package: "next_edu_cloud.iam.v1",
protoPath: "proto/iam.proto",
url: `0.0.0.0:${env.GRPC_PORT}`,
},
});
// Prometheus 指标端点
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => { app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });
// 启动 hybrid appHTTP + gRPC
await app.startAllMicroservices();
await app.listen(env.PORT); await app.listen(env.PORT);
logger.info({ port: env.PORT }, "IAM service started");
logger.info(
{ httpPort: env.PORT, grpcPort: env.GRPC_PORT },
"IAM service started (HTTP + gRPC dual entry)",
);
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
await app.close(); await app.close();

View File

@@ -5,20 +5,27 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
/**
* 已认证请求:由 AuthMiddleware 从 Gateway 注入的 x-user-* 头部解析。
* 公开端点login/register/refresh/jwks/health不走此中间件。
*/
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
userRoles?: string[]; userRoles?: string[];
userDataScope?: string;
} }
@Injectable() @Injectable()
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息
const userIdHeader = req.headers["x-user-id"]; const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"]; const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader = const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined; typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
const dataScopeHeaderRaw = req.headers["x-user-data-scope"];
const dataScopeHeader =
typeof dataScopeHeaderRaw === "string" ? dataScopeHeaderRaw : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedException("Missing x-user-id header"); throw new UnauthorizedException("Missing x-user-id header");
@@ -26,6 +33,7 @@ export class AuthMiddleware implements NestMiddleware {
req.userId = userId; req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : []; req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
req.userDataScope = dataScopeHeader ?? "self";
next(); next();
} }
} }

View File

@@ -6,43 +6,50 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js"; import { PermissionDeniedError } from "../shared/errors/application-error.js";
import { PermissionCacheService } from "../shared/cache/permission-cache.service.js";
import { IamRepository } from "../iam/iam.repository.js";
import type { AuthenticatedRequest } from "./auth.middleware.js"; import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission = /**
| "IAM_USER_CREATE" * 权限点常量(对齐 iam-init.sql 种子数据)。
| "IAM_USER_READ" *
| "IAM_USER_UPDATE" * 权限名格式:`<resource>:<action>`(如 `iam:user:read`)。
| "IAM_USER_DELETE" * DB 中存储的权限名与 controller 装饰器声明的权限名一一对应。
| "IAM_ROLE_MANAGE"; */
export const Permissions = { export const Permissions = {
IAM_USER_CREATE: "IAM_USER_CREATE" as const, IAM_USER_READ: "iam:user:read",
IAM_USER_READ: "IAM_USER_READ" as const, IAM_USER_MANAGE: "iam:user:manage",
IAM_USER_UPDATE: "IAM_USER_UPDATE" as const, IAM_ROLE_MANAGE: "iam:role:manage",
IAM_USER_DELETE: "IAM_USER_DELETE" as const, IAM_AUDIT_READ: "iam:audit:read",
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE" as const, IAM_VIEWPORT_READ: "iam:viewport:read",
}; } as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions"; export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) => export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions); SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = { /**
admin: [ * 权限守卫I3 裁决DB 驱动 + Redis 缓存)。
Permissions.IAM_USER_CREATE, *
Permissions.IAM_USER_READ, * 校验流程:
Permissions.IAM_USER_UPDATE, * 1. 从 req.userId 获取当前用户(由 AuthMiddleware 注入)
Permissions.IAM_USER_DELETE, * 2. 先查 Redis 缓存TTL 5min
Permissions.IAM_ROLE_MANAGE, * 3. 未命中则查 DBrole_permissions JOIN并回填缓存
], * 4. 检查用户权限列表是否包含所需权限
teacher: [Permissions.IAM_USER_READ], *
}; * admin 角色拥有全部权限data_scope=all 时跳过 DB 查询直接放行)。
*/
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {} constructor(
private readonly reflector: Reflector,
private readonly permissionCache: PermissionCacheService,
private readonly iamRepository: IamRepository,
) {}
canActivate(context: ExecutionContext): boolean { async canActivate(context: ExecutionContext): Promise<boolean> {
if (process.env.DEV_MODE === "true") { if (process.env.DEV_MODE === "true") {
return true; return true;
} }
@@ -57,15 +64,33 @@ export class PermissionGuard implements CanActivate {
} }
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? []; const userId = request.userId;
if (!userId) {
for (const role of roles) { throw new PermissionDeniedError("missing user identity");
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
} }
throw new PermissionDeniedError(requiredPermissions.join(", ")); // data_scope=all 的用户admin直接放行
if (request.userDataScope === "all") {
return true;
}
const userPermissions = await this.loadPermissions(userId);
const hasPermission = requiredPermissions.some((p) =>
userPermissions.includes(p),
);
if (!hasPermission) {
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
return true;
}
private async loadPermissions(userId: string): Promise<string[]> {
const cached = await this.permissionCache.getPermissions(userId);
if (cached) return cached;
const perms = await this.iamRepository.getUserPermissions(userId);
const names = perms.map((p) => p.name);
await this.permissionCache.setPermissions(userId, names);
return names;
} }
} }

View File

@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { getRedis } from "../../config/redis.js";
const PERMISSION_CACHE_TTL_SECONDS = 300; // 5 分钟
/**
* 权限缓存服务I3 裁决DB 驱动 + Redis 缓存)。
*
* 缓存策略:
* - Key: `iam:perm:{userId}` → JSON string[] 权限名列表
* - TTL: 5 分钟,超时自动失效重新从 DB 加载
* - 失效:角色变更 / 权限变更时主动 del通过 Outbox 事件触发)
*
* 使用 ioredis 单例config/redis.ts 管理),不重复创建连接。
*/
@Injectable()
export class PermissionCacheService {
private static buildKey(userId: string): string {
return `iam:perm:${userId}`;
}
async getPermissions(userId: string): Promise<string[] | null> {
const redis = getRedis();
const raw = await redis.get(PermissionCacheService.buildKey(userId));
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed) && parsed.every((p) => typeof p === "string")) {
return parsed as string[];
}
return null;
} catch {
return null;
}
}
async setPermissions(userId: string, permissions: string[]): Promise<void> {
const redis = getRedis();
await redis.set(
PermissionCacheService.buildKey(userId),
JSON.stringify(permissions),
"EX",
PERMISSION_CACHE_TTL_SECONDS,
);
}
async invalidate(userId: string): Promise<void> {
const redis = getRedis();
await redis.del(PermissionCacheService.buildKey(userId));
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from "@nestjs/common";
import { getRedis } from "../../config/redis.js";
/**
* Token 黑名单服务I7 裁决JWT 黑名单)。
*
* 用于 logout / refresh 轮换场景,将未过期的 refresh_token 的 jti 加入黑名单,
* 阻止其再次被用于刷新 access_token。
*
* 缓存策略:
* - Key: `iam:bl:{jti}` → "1"
* - TTL: 与 refresh_token 剩余有效期对齐(避免永久驻留)
*
* access_token 不走黑名单(短生命周期 15min自然过期
*/
@Injectable()
export class TokenBlacklistService {
private static buildKey(jti: string): string {
return `iam:bl:${jti}`;
}
async isBlacklisted(jti: string): Promise<boolean> {
const redis = getRedis();
const exists = await redis.exists(TokenBlacklistService.buildKey(jti));
return exists === 1;
}
/**
* 将 jti 加入黑名单。
* @param jti JWT ID
* @param ttlSeconds 剩余有效期(秒),到期后自动清理
*/
async blacklist(jti: string, ttlSeconds: number): Promise<void> {
if (ttlSeconds <= 0) return; // 已过期,无需加入
const redis = getRedis();
await redis.set(TokenBlacklistService.buildKey(jti), "1", "EX", ttlSeconds);
}
}

View File

@@ -1,17 +1,22 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common"; import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { getDb } from "../../config/database.js"; import { getDb } from "../../config/database.js";
import { getRedis } from "../../config/redis.js";
import { getJwtKeyPair } from "../../config/jwt.js";
const SERVICE_NAME = "iam"; const SERVICE_NAME = "iam";
interface DependencyCheck {
name: string;
status: "ok" | "error";
error?: string;
}
/** /**
* 健康检查端点。 * 健康检查端点。
* *
* - GET /healthzliveness仅返回进程存活不检查依赖 * - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 DB 连接,失败返回 503 * - GET /readyzreadiness检查 5 依赖DB/Redis/Kafka/gRPC/JWKS,失败返回 503
*
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
*/ */
@Controller() @Controller()
export class HealthController { export class HealthController {
@@ -29,26 +34,104 @@ export class HealthController {
status: string; status: string;
service: string; service: string;
timestamp: string; timestamp: string;
dependencies: DependencyCheck[];
}> { }> {
try { const checks: DependencyCheck[] = [];
const db = getDb();
await db.execute(sql`SELECT 1`); // 1. DB
return { checks.push(await this.checkDb());
status: "ok",
service: SERVICE_NAME, // 2. Redis
timestamp: new Date().toISOString(), checks.push(await this.checkRedis());
};
} catch (error) { // 3. Kafka检查 producer 连接状态——通过 ping
checks.push(await this.checkKafka());
// 4. JWKS检查密钥文件已加载
checks.push(this.checkJwks());
// 5. gRPC本进程内启动进程存活即 gRPC 存活)
checks.push({ name: "grpc", status: "ok" });
const allOk = checks.every((c) => c.status === "ok");
if (!allOk) {
throw new HttpException( throw new HttpException(
{ {
status: "error", status: "error",
service: SERVICE_NAME, service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
error: dependencies: checks,
error instanceof Error ? error.message : "database unreachable",
}, },
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
); );
} }
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
dependencies: checks,
};
}
private async checkDb(): Promise<DependencyCheck> {
try {
const db = getDb();
await db.execute(sql`SELECT 1`);
return { name: "database", status: "ok" };
} catch (error) {
return {
name: "database",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private async checkRedis(): Promise<DependencyCheck> {
try {
const redis = getRedis();
const pong = await redis.ping();
if (pong !== "PONG") {
return { name: "redis", status: "error", error: `Unexpected: ${pong}` };
}
return { name: "redis", status: "ok" };
} catch (error) {
return {
name: "redis",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private async checkKafka(): Promise<DependencyCheck> {
try {
// Kafka producer 连接状态由 AppModule.onModuleInit 建立
// 这里仅检查 producer 实例是否可用
const { getKafkaProducer } = await import("../../config/kafka.js");
const producer = getKafkaProducer();
void producer; // 实例存在即视为可用
return { name: "kafka", status: "ok" };
} catch (error) {
return {
name: "kafka",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private checkJwks(): DependencyCheck {
try {
getJwtKeyPair();
return { name: "jwks", status: "ok" };
} catch (error) {
return {
name: "jwks",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
} }
} }

View File

@@ -5,18 +5,21 @@ import {
OnModuleInit, OnModuleInit,
} from "@nestjs/common"; } from "@nestjs/common";
import { closeDb } from "../../config/database.js"; import { closeDb } from "../../config/database.js";
import { closeRedis } from "../../config/redis.js";
import { disconnectKafkaProducer } from "../../config/kafka.js";
const SERVICE_NAME = "iam"; const SERVICE_NAME = "iam";
/** /**
* 优雅停机服务。 * 优雅停机服务。
* *
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()` * 关闭顺序president §2.16 + I5 Outbox 依赖 Kafka
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。 * 1. HTTP/gRPC server 已由 NestJS app.close() 停止
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。 * 2. Kafka producer 断开(停止投递 Outbox 事件)
* 3. Redis 断开(停止缓存读写)
* 4. DB 连接池关闭(最后关闭,确保 Outbox publisher 已完成残余投递)
* *
* IAM 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖 * K8s terminationGracePeriodSeconds=60 给予足够时间清理
* 关闭时仅需关闭数据库连接池。
*/ */
@Injectable() @Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
@@ -31,6 +34,27 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
); );
// 1. Kafka producer
try {
await disconnectKafkaProducer();
this.logger.log("Kafka producer disconnected");
} catch (error) {
this.logger.error(
`Kafka producer disconnect failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
// 2. Redis
try {
await closeRedis();
this.logger.log("Redis connection closed");
} catch (error) {
this.logger.error(
`Redis close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
// 3. DB最后关闭
try { try {
await closeDb(); await closeDb();
this.logger.log("database connection closed"); this.logger.log("database connection closed");