feat(iam): 完整实现 iam 身份认证与权限服务
包含 jwt/jwks/audit/grpc、rbac、cache、redis/kafka 配置等完整实现
This commit is contained in:
@@ -233,26 +233,32 @@
|
||||
| AppModule 注册 | HealthModule 必须在 `app.module.ts` imports 数组显式声明,否则 NestFactory 不扫描 HealthController |
|
||||
| 增量编译陷阱 | `tsconfig.json` 显式 `"incremental": false` 覆盖 base,避免 .tsbuildinfo 导致 nest watch 不 emit |
|
||||
|
||||
### 2.3 iam(TS/NestJS,P2)
|
||||
### 2.3 iam(TS/NestJS)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 认证 | 登录/登出/JWT/2FA,RS256 非对称签名 |
|
||||
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
|
||||
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
|
||||
| DataScope 解析 | 6 级数据范围,注入 JWT payload |
|
||||
| JWT payload | `{ userId, roles, permissions(bitmap), dataScope, exp }` |
|
||||
| Token TTL | access 15min / refresh 7day,refresh 用 Redis 黑名单失效 |
|
||||
| 权限缓存 | `getEffectivePermissions` 结果 Redis 缓存 TTL 5 分钟,角色变更主动失效 |
|
||||
| schema 表 | users / roles / permissions / role_permissions / role_viewports / parent_student_relations / class_subject_teachers |
|
||||
| ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 |
|
||||
| Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()`;select 返回字段名按 schema 定义(如 `r.iam_roles` 而非 `r.roles`) |
|
||||
| 健康检查依赖 | `readyz` 用 `db.execute(sql\`SELECT 1\`)` 校验连接,不要依赖 typeorm DataSource(IAM 用 Drizzle,无 typeorm) |
|
||||
| Gateway 身份传递 | Controller 直接读 `req.headers['x-user-id']` / `x-user-roles`,不要依赖未注册的 AuthMiddleware 的 `AuthenticatedRequest` |
|
||||
| DEV_MODE 登录 | DEV_MODE=true 时 Gateway 接受 `Bearer dev-token` 注入固定身份,IAM 仍支持真实 JWT(HS256,P2 应改 RS256) |
|
||||
| P2 公开路径白名单 | Gateway `publicPaths` map 含 `/iam/register`/`/iam/login`/`/iam/refresh`,AuthMiddleware 跳过鉴权避免死锁 |
|
||||
| P2 视口过滤 | `getUserViewports` 按 `requiredPermission` 过滤 + `sortOrder` 字典序排序,无权限要求的视口全员可见 |
|
||||
| P2 JWT payload | HS256 签名含 `sub/email/roles/dataScope/type`,register 自动分配 teacher 角色(TEACHER_ROLE_ID 固定 UUID) |
|
||||
| 场景 | 技术/规则 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 双入口 | REST `/v1/iam/*` + gRPC 50052(package `next_edu_cloud.iam.v1`),同一 IamService 实例共用(Hybrid app `connectMicroservice`) |
|
||||
| 认证 | RS256 非对称签名,私钥本地文件(`IAM_PRIVATE_KEY_PATH`),公钥通过 JWKS 端点暴露给 Gateway |
|
||||
| JWT payload | `{ sub, email, roles, dataScope, type, iss, aud, jti(仅 refresh), kid(header) }`,register 自动分配 teacher 角色 |
|
||||
| Token TTL | access 15min / refresh 7day,refresh 含 jti,轮换时旧 jti 加入 Redis 黑名单 |
|
||||
| RBAC | 角色/权限/角色-权限查询 + `getEffectivePermissions(userId)` API,三层角色模型(system/organization/temporary,level 0/1/2) |
|
||||
| 视口配置 | 4 层模型(导航/路由/组件/数据),`iam_role_viewports` 表含 `level` ENUM(admin/teacher/student/parent) |
|
||||
| DataScope 解析 | 6 级:self/subject/class/grade/school/all(SUBJECT 替代 DISTRICT),注入 JWT payload |
|
||||
| 权限校验 | PermissionGuard(APP_GUARD)DB 驱动 + Redis 缓存 TTL 5min,`data_scope=all` 直放行,Key `iam:perm:{userId}` |
|
||||
| Token 黑名单 | Redis Key `iam:bl:{jti}` TTL 与 refresh_token 剩余有效期对齐,logout/refresh 时写入 |
|
||||
| 审计日志 | `iam_user_audit_log` 表 + `AuditCreated` Kafka 事件,登录/角色变更/密码修改等关键操作记录 |
|
||||
| 家长-学生关系 | `iam_student_guardians` 表(unique(studentId, guardianId)),`GetChildrenByParent` RPC + `GET /v1/iam/children` REST |
|
||||
| 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 |
|
||||
| Outbox | shared-ts `OutboxModule.forRoot({ config, db, kafkaProducer })`,表名 `iam_outbox`,topic `edu.iam.user.events`/`edu.iam.role.events`/`edu.iam.audit.created` |
|
||||
| AuthMiddleware 注册 | `app.module.ts` `configure()` 注册于 me/logout/viewports/permissions/roles/audit/children 路由,解析 `x-user-*` 头 |
|
||||
| 公开路径 | register/login/refresh/jwks.json 无 `@RequirePermission()`,PermissionGuard 旁路 |
|
||||
| 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 end(LifecycleService `onApplicationShutdown`) |
|
||||
|
||||
### 2.4 core-edu(TS/NestJS,P3)
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@ syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.events.v1;
|
||||
|
||||
// Cross-service event contracts published by CoreEdu via the transactional
|
||||
// outbox pattern and consumed by downstream services (notifications, analytics,
|
||||
// audit, etc.). Topics follow the convention edu.{domain}.events.
|
||||
// Cross-service event contracts published via the transactional outbox pattern
|
||||
// and consumed by downstream services (notifications, analytics, audit, etc.).
|
||||
// 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.homework.events <- homework.assigned / homework.submitted / homework.graded
|
||||
// edu.grade.events <- grade.recorded / grade.updated
|
||||
// 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 {
|
||||
string event_id = 1;
|
||||
@@ -58,3 +61,51 @@ message GradeEvent {
|
||||
string action = 8;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,33 @@ syntax = "proto3";
|
||||
package next_edu_cloud.iam.v1;
|
||||
|
||||
// IamService 定义身份与访问管理契约
|
||||
// P2: REST 实现,P3 起转 gRPC
|
||||
// 双入口策略(president §2.16):REST 供 gateway 透传 + admin-portal 直连,
|
||||
// gRPC 供 BFF 聚合调用。同一 Application Service 同时被两种 Controller 调用。
|
||||
// gRPC 端口 50052,P2 即启用(I1 裁决)。
|
||||
service IamService {
|
||||
// 认证类
|
||||
rpc Register(RegisterRequest) returns (AuthResponse);
|
||||
rpc Login(LoginRequest) returns (AuthResponse);
|
||||
rpc RefreshToken(RefreshTokenRequest) returns (TokenPair);
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse);
|
||||
|
||||
// 用户信息类
|
||||
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 {
|
||||
string email = 1;
|
||||
string password = 2;
|
||||
@@ -26,8 +45,13 @@ message RefreshTokenRequest {
|
||||
string refresh_token = 1;
|
||||
}
|
||||
|
||||
message GetUserInfoRequest {
|
||||
string user_id = 1;
|
||||
message LogoutRequest {
|
||||
string refresh_token = 1;
|
||||
string user_id = 2;
|
||||
}
|
||||
|
||||
message LogoutResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message AuthResponse {
|
||||
@@ -41,10 +65,95 @@ message TokenPair {
|
||||
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 {
|
||||
string id = 1;
|
||||
string email = 2;
|
||||
string name = 3;
|
||||
repeated string roles = 4;
|
||||
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
189
pnpm-lock.yaml
generated
@@ -82,8 +82,88 @@ importers:
|
||||
specifier: ^5.6.0
|
||||
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-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:
|
||||
dependencies:
|
||||
better-sqlite3:
|
||||
@@ -107,7 +187,7 @@ importers:
|
||||
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/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':
|
||||
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)
|
||||
@@ -204,7 +284,7 @@ importers:
|
||||
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/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':
|
||||
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)
|
||||
@@ -271,7 +351,7 @@ importers:
|
||||
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/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':
|
||||
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)
|
||||
@@ -345,12 +425,24 @@ importers:
|
||||
|
||||
services/iam:
|
||||
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':
|
||||
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/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':
|
||||
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)
|
||||
@@ -372,9 +464,15 @@ importers:
|
||||
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)
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.11.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.3
|
||||
kafkajs:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.4
|
||||
mysql2:
|
||||
specifier: ^3.11.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)
|
||||
'@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/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':
|
||||
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)
|
||||
@@ -505,7 +603,7 @@ importers:
|
||||
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/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':
|
||||
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)
|
||||
@@ -1312,6 +1410,11 @@ packages:
|
||||
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1422,6 +1525,42 @@ packages:
|
||||
'@nestjs/websockets':
|
||||
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':
|
||||
resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==}
|
||||
peerDependencies:
|
||||
@@ -1494,6 +1633,10 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2503,6 +2646,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -6404,6 +6550,13 @@ snapshots:
|
||||
'@grpc/proto-loader': 0.8.1
|
||||
'@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':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
@@ -6563,7 +6716,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nuxtjs/opencollective': 0.3.2
|
||||
@@ -6575,14 +6728,28 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- 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)':
|
||||
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/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
|
||||
cors: 2.8.5
|
||||
express: 4.22.1
|
||||
@@ -6642,6 +6809,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@14.2.33':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -8135,6 +8304,10 @@ snapshots:
|
||||
'@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': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
|
||||
@@ -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` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`email` VARCHAR(255) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
`data_scope` ENUM('self','class','grade','school','district','all') NOT NULL DEFAULT 'self',
|
||||
-- DataScope 6 级(president §3.2:SUBJECT 替代 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,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `iam_users_email_unique` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. iam_roles:角色表(三层角色模型)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_roles` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`name` VARCHAR(50) NOT 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`),
|
||||
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;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. iam_user_roles:用户-角色关联
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_user_roles` (
|
||||
`user_id` CHAR(36) NOT NULL,
|
||||
`role_id` CHAR(36) NOT NULL,
|
||||
`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;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. iam_permissions:权限点
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_permissions` (
|
||||
`id` CHAR(36) 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`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. iam_role_permissions:角色-权限关联
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_role_permissions` (
|
||||
`role_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;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. iam_refresh_tokens:刷新令牌(含 jti 用于黑名单追踪)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_refresh_tokens` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`user_id` CHAR(36) NOT NULL,
|
||||
`token_hash` VARCHAR(255) NOT NULL,
|
||||
-- JWT ID,用于黑名单追踪(I7:JWT 黑名单)
|
||||
`jti` VARCHAR(36) NULL,
|
||||
`expires_at` TIMESTAMP NOT NULL,
|
||||
`revoked_at` TIMESTAMP NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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;
|
||||
|
||||
-- 视口配置表(4 层模型:L1 导航 / L2 路由 / L3 组件 / L4 数据)
|
||||
-- ============================================================
|
||||
-- 7. iam_role_viewports:视口配置表
|
||||
-- 4 层模型:L1 导航 / L2 路由 / L3 组件 / L4 数据
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS `iam_role_viewports` (
|
||||
`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,
|
||||
`sort_order` VARCHAR(10) NOT NULL DEFAULT '0',
|
||||
`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,
|
||||
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;
|
||||
|
||||
-- 种子数据:默认角色
|
||||
INSERT IGNORE INTO `iam_roles` (`id`, `name`, `description`) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'teacher', '教师角色'),
|
||||
('00000000-0000-0000-0000-000000000002', 'admin', '管理员角色');
|
||||
-- ============================================================
|
||||
-- 8. iam_student_guardians:学生-家长关系表(I6 裁决)
|
||||
-- ============================================================
|
||||
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 引用)
|
||||
-- ============================================================
|
||||
INSERT IGNORE INTO `iam_permissions` (`id`, `name`, `resource`, `action`) VALUES
|
||||
('00000000-0000-0000-0000-000000000101', 'classes:read', 'classes', 'read'),
|
||||
('00000000-0000-0000-0000-000000000102', 'classes:create', 'classes', 'create'),
|
||||
('00000000-0000-0000-0000-000000000103', 'classes:update', 'classes', 'update'),
|
||||
('00000000-0000-0000-0000-000000000104', 'classes:delete', 'classes', 'delete'),
|
||||
('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-000000000203', 'iam:role:manage', 'iam', 'role:manage');
|
||||
('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-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
|
||||
('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-000000000103'),
|
||||
('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
|
||||
('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-000000000103'),
|
||||
('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-000000000202'),
|
||||
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000203');
|
||||
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000301');
|
||||
|
||||
-- 种子数据:teacher 角色视口(L1 导航)
|
||||
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES
|
||||
('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'),
|
||||
('00000000-0000-0000-0000-000000000a03', '00000000-0000-0000-0000-000000000001', 'profile', '个人中心', '/profile', 'user', '9', NULL);
|
||||
-- ============================================================
|
||||
-- 种子数据:student 角色权限(classes:read + exam:read)
|
||||
-- ============================================================
|
||||
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
|
||||
('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
|
||||
('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'),
|
||||
('00000000-0000-0000-0000-000000000b03', '00000000-0000-0000-0000-000000000002', 'iam', '用户管理', '/iam/users', 'shield', '2', 'iam:user:read'),
|
||||
('00000000-0000-0000-0000-000000000b04', '00000000-0000-0000-0000-000000000002', 'profile', '个人中心', '/profile', 'user', '9', NULL);
|
||||
-- ============================================================
|
||||
-- 种子数据:parent 角色权限(classes:read + viewport:read)
|
||||
-- ============================================================
|
||||
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
|
||||
('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000101'),
|
||||
('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);
|
||||
|
||||
@@ -1,44 +1,135 @@
|
||||
# IAM Service
|
||||
|
||||
身份与访问管理服务(Identity and Access Management),P2 身份阶段交付。
|
||||
身份与访问管理服务(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)管理
|
||||
- Access / Refresh Token 签发与校验
|
||||
- 用户信息查询(供 BFF / Gateway 聚合)
|
||||
- 用户注册 / 登录 / 刷新令牌 / 登出
|
||||
- JWT RS256 签发(access 15min + refresh 7day,jti 黑名单)
|
||||
- 角色(Role)与权限(Permission)管理(三层角色模型:system/organization/temporary)
|
||||
- DataScope 6 级数据范围(self/subject/class/grade/school/all)
|
||||
- 视口(Viewport)配置查询
|
||||
- 学生-家长关系管理(`GetChildrenByParent`)
|
||||
- 审计日志(`iam_user_audit_log` + `AuditCreated` 事件)
|
||||
- JWKS 公钥暴露(供 api-gateway 验签)
|
||||
- 双入口:REST `/v1/iam/*` + gRPC 50052(package `next_edu_cloud.iam.v1`,12 RPC)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- NestJS 10 + TypeScript(ESM)
|
||||
- Drizzle ORM + MySQL
|
||||
- bcrypt 密码哈希
|
||||
- jsonwebtoken(P2 骨架使用 HS256,后续切 RS256)
|
||||
- NestJS 10 + TypeScript(ESM,NodeNext)
|
||||
- Drizzle ORM + MySQL(10 张表)
|
||||
- bcrypt 密码哈希(cost ≥ 12)
|
||||
- jsonwebtoken RS256(本地文件密钥)
|
||||
- ioredis(权限缓存 TTL 5min + token jti 黑名单)
|
||||
- kafkajs + shared-ts OutboxModule(事务性事件发布)
|
||||
- @nestjs/microservices + @grpc/grpc-js(gRPC server)
|
||||
- pino 日志 / prom-client 指标 / OpenTelemetry 链路
|
||||
|
||||
## 端口
|
||||
|
||||
默认 `3002`,通过 `PORT` 环境变量覆盖。
|
||||
| 服务 | 端口 | 备注 |
|
||||
| -------- | ----- | ------------------------ |
|
||||
| iam HTTP | 3002 | REST 入口(`PORT`) |
|
||||
| iam gRPC | 50052 | gRPC 入口(`GRPC_PORT`) |
|
||||
|
||||
## API
|
||||
## 环境变量
|
||||
|
||||
| Method | Path | 说明 |
|
||||
| ------ | --------------- | --------------------------------- |
|
||||
| POST | `/iam/register` | 注册 |
|
||||
| POST | `/iam/login` | 登录 |
|
||||
| POST | `/iam/refresh` | 刷新令牌 |
|
||||
| GET | `/iam/me` | 当前用户信息(需 `x-user-id` 头) |
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| ----------------------------- | ------------------------------ | ---------------- |
|
||||
| `PORT` | HTTP 端口 | `3002` |
|
||||
| `GRPC_PORT` | gRPC 端口 | `50052` |
|
||||
| `DATABASE_URL` | MySQL 连接串 | — |
|
||||
| `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` | 密钥 ID(kid,用于 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 RPC,proto 定义见 `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 /readyz` | 就绪探针(readiness),检查 DB 连接,失败返回 503 | 无 |
|
||||
| 端点 | 用途 | 鉴权 |
|
||||
| -------------- | ------------------------------------------------------------------------- | ---- |
|
||||
| `GET /healthz` | 存活探针(liveness),仅返回进程状态 | 无 |
|
||||
| `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
|
||||
```
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# 模块架构设计文档 — iam
|
||||
|
||||
> AI:ai02(TS / 身份认证)
|
||||
> 阶段:阶段 2 交付物
|
||||
> 日期:2026-07-09
|
||||
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
|
||||
> 状态:待 coord 交叉审查
|
||||
> 模块:IAM(身份认证与权限)
|
||||
> 版本:v1.0(最终态,对齐 coord-final-decisions I1-I8 + president §2.15/§2.16/§3.2/§5.5)
|
||||
> 日期:2026-07-10
|
||||
> 关联:
|
||||
>
|
||||
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)
|
||||
> - [仲裁决策](../../../docs/architecture/coord-final-decisions.md)
|
||||
> - [总裁裁决](../../../docs/architecture/president-final-rulings.md)
|
||||
> - [iam 对接契约](../../../docs/architecture/issues/contracts/iam_contract.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,65 +19,61 @@
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["客户端 / Gateway / BFF"]
|
||||
Req[HTTP 请求<br/>带 x-user-id / x-user-roles 头]
|
||||
REST[HTTP 请求<br/>带 x-user-id / x-user-roles / x-user-data-scope 头]
|
||||
GRPC[gRPC 调用<br/>next_edu_cloud.iam.v1]
|
||||
end
|
||||
|
||||
subgraph NestJS["iam 服务(NestJS)"]
|
||||
subgraph NestJS["iam 服务(NestJS Hybrid App)"]
|
||||
direction TB
|
||||
MW[AuthMiddleware<br/>❌ 当前未注册,P2 仍走 header 直读]
|
||||
Guard[PermissionGuard<br/>APP_GUARD 全局守卫]
|
||||
MW[AuthMiddleware<br/>已注册:me/logout/viewports/<br/>permissions/roles/audit/children]
|
||||
Guard[PermissionGuard<br/>APP_GUARD 全局守卫<br/>DB 驱动 + Redis 缓存]
|
||||
Filter[GlobalErrorFilter<br/>全局异常过滤器]
|
||||
|
||||
subgraph Controllers["Controller 层"]
|
||||
IamCtl[IamController<br/>/iam/register, login, refresh, me]
|
||||
RbacCtl[RbacController<br/>/iam/viewports, permissions, roles]
|
||||
UserCtl[UserController<br/>P2 新增: 用户 CRUD]
|
||||
RoleCtl[RoleController<br/>P2 新增: 角色 CRUD]
|
||||
ViewportCtl[ViewportController<br/>P2 新增: 视口 CRUD]
|
||||
JwksCtl[JwksController<br/>P2 新增: RS256 公钥暴露]
|
||||
subgraph Controllers["Controller 层(双入口)"]
|
||||
IamCtl[IamController<br/>REST /v1/iam/*]
|
||||
RbacCtl[RbacController<br/>REST /v1/iam/roles, /permissions]
|
||||
AuditCtl[AuditController<br/>REST /v1/iam/audit]
|
||||
JwksCtl[JwksController<br/>GET /v1/iam/.well-known/jwks.json]
|
||||
GrpcCtl[IamGrpcController<br/>12 RPC @ GrpcMethod]
|
||||
end
|
||||
|
||||
subgraph Services["Application Service 层"]
|
||||
IamSvc[IamService<br/>认证编排]
|
||||
RbacSvc[RbacService<br/>RBAC 编排]
|
||||
UserSvc[UserService<br/>用户领域编排]
|
||||
CacheSvc[PermissionCacheService<br/>Redis 权限缓存]
|
||||
end
|
||||
|
||||
subgraph Domain["Domain 层(P2 轻量)"]
|
||||
UserEntity[UserEntity<br/>聚合根]
|
||||
RoleEntity[RoleEntity<br/>聚合根]
|
||||
IamSvc[IamService<br/>认证 + RBAC + 审计编排<br/>REST 与 gRPC 共用]
|
||||
CacheSvc[PermissionCacheService<br/>Redis 权限缓存 TTL 5min]
|
||||
BlacklistSvc[TokenBlacklistService<br/>Redis jti 黑名单]
|
||||
JwksSvc[JwksService<br/>PEM→JWK 转换]
|
||||
end
|
||||
|
||||
subgraph Repo["Repository 层"]
|
||||
IamRepo[IamRepository<br/>Drizzle 查询]
|
||||
RbacRepo[RbacRepository<br/>Drizzle 查询]
|
||||
IamRepo[IamRepository<br/>Drizzle 查询 10 表]
|
||||
end
|
||||
|
||||
subgraph Outbox["Outbox 模块"]
|
||||
OutboxTbl[(iam_outbox 表)]
|
||||
Relay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
|
||||
subgraph Outbox["Outbox 模块(shared-ts)"]
|
||||
OutboxPub[OutboxPublisher<br/>事务内写入]
|
||||
OutboxRelay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
|
||||
end
|
||||
|
||||
subgraph Infra["基础设施"]
|
||||
Db[(MySQL<br/>iam_db)]
|
||||
Redis[(Redis<br/>权限缓存 + token 黑名单)]
|
||||
Kafka[(Kafka<br/>edu.identity.user.* topic)]
|
||||
Kafka[(Kafka<br/>edu.iam.* topics)]
|
||||
end
|
||||
end
|
||||
|
||||
Req --> MW
|
||||
REST --> MW
|
||||
MW --> Guard
|
||||
GRPC --> Guard
|
||||
Guard --> Controllers
|
||||
Controllers --> Services
|
||||
Services --> Domain
|
||||
Services --> Repo
|
||||
Services --> CacheSvc
|
||||
Repo --> Db
|
||||
Controllers --> IamSvc
|
||||
IamSvc --> IamRepo
|
||||
IamSvc --> CacheSvc
|
||||
IamSvc --> BlacklistSvc
|
||||
IamSvc --> OutboxPub
|
||||
IamRepo --> Db
|
||||
CacheSvc --> Redis
|
||||
Services --> OutboxTbl
|
||||
OutboxTbl --> Relay
|
||||
Relay --> Kafka
|
||||
BlacklistSvc --> Redis
|
||||
OutboxPub --> OutboxRelay
|
||||
OutboxRelay --> Kafka
|
||||
Controllers -.异常.-> Filter
|
||||
```
|
||||
|
||||
@@ -81,64 +81,50 @@ flowchart TB
|
||||
|
||||
```
|
||||
请求进入
|
||||
→ AuthMiddleware(P2 仍不注册,Controller 直读 header)
|
||||
→ PermissionGuard(APP_GUARD,DEV_MODE 旁路 + DB 驱动权限校验)
|
||||
→ Controller Handler(Zod 校验 body)
|
||||
→ Application Service(业务编排)
|
||||
→ AuthMiddleware(注册于 me/logout/viewports/permissions/roles/audit/children)
|
||||
→ PermissionGuard(APP_GUARD,data_scope=all 直放行 + DB 驱动 + Redis 缓存)
|
||||
→ Controller Handler(Zod 校验 body / @GrpcMethod)
|
||||
→ Application Service(业务编排:Repository + Cache + Outbox + 审计)
|
||||
→ Repository(Drizzle 查询)
|
||||
→ 异常抛出
|
||||
→ GlobalErrorFilter(统一兜底,注入 traceId)
|
||||
→ 响应返回
|
||||
```
|
||||
|
||||
### 1.3 目录结构(P2 目标态)
|
||||
### 1.3 目录结构(最终态)
|
||||
|
||||
```
|
||||
services/iam/src/
|
||||
├─ iam/ # 限界上下文:认证
|
||||
│ ├─ iam.controller.ts # 认证端点(register/login/refresh/me/logout)
|
||||
│ ├─ iam.service.ts # 认证编排
|
||||
│ ├─ iam.repository.ts # 用户/refresh_token 查询
|
||||
│ ├─ iam.schema.ts # users / refresh_tokens 表
|
||||
│ ├─ iam.dto.ts # Zod schema
|
||||
│ └─ domain/
|
||||
│ └─ user.entity.ts # UserEntity 聚合根(P2 新增)
|
||||
├─ rbac/ # 限界上下文:RBAC(P2 从 iam/ 拆出)
|
||||
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
|
||||
│ ├─ role.controller.ts # 角色 CRUD(P2 新增)
|
||||
│ ├─ permission.controller.ts # 权限点 CRUD(P2 新增)
|
||||
│ ├─ viewport.controller.ts # 视口配置 CRUD(P2 新增)
|
||||
│ ├─ rbac.service.ts # RBAC 编排
|
||||
│ ├─ rbac.repository.ts # 角色/权限/视口查询
|
||||
│ ├─ rbac.schema.ts # roles / permissions / role_permissions / role_viewports 表
|
||||
│ └─ domain/
|
||||
│ └─ role.entity.ts # RoleEntity 聚合根(P2 新增)
|
||||
├─ jwks/ # 限界上下文:JWT 公钥暴露(P2 新增)
|
||||
│ ├─ jwks.controller.ts # GET /iam/.well-known/jwks.json
|
||||
│ ├─ jwks.service.ts # 密钥加载 + JWK Set 生成
|
||||
│ └─ jwks.repository.ts # 密钥元数据持久化(可选)
|
||||
├─ cache/ # 限界上下文:Redis 缓存(P2 新增)
|
||||
│ ├─ permission-cache.service.ts # getEffectivePermissions 缓存
|
||||
│ └─ token-blacklist.service.ts # refresh token 黑名单
|
||||
├─ outbox/ # Outbox 模式(P2 新增)
|
||||
│ ├─ outbox.schema.ts # iam_outbox 表
|
||||
│ ├─ outbox.publisher.ts # 写入 outbox(事务内)
|
||||
│ └─ outbox.relay-worker.ts # 后台轮询 + Kafka 投递
|
||||
├─ iam/ # 限界上下文:认证 + RBAC + 审计
|
||||
│ ├─ iam.controller.ts # REST /v1/iam 入口
|
||||
│ ├─ iam.grpc.controller.ts # gRPC 12 RPC 入口
|
||||
│ ├─ rbac.controller.ts # REST /v1/iam/roles, /permissions
|
||||
│ ├─ audit.controller.ts # REST /v1/iam/audit
|
||||
│ ├─ jwks.controller.ts # REST /v1/iam/.well-known/jwks.json
|
||||
│ ├─ jwks.service.ts # PEM→JWK 转换
|
||||
│ ├─ iam.service.ts # Application Service(12 RPC + Outbox + 审计)
|
||||
│ ├─ iam.repository.ts # Drizzle 查询(10 表)
|
||||
│ ├─ iam.schema.ts # 10 张表 schema + DataScope/RoleType 枚举
|
||||
│ └─ iam.dto.ts # Zod schema
|
||||
├─ config/
|
||||
│ ├─ database.ts # Drizzle 池(已有)
|
||||
│ ├─ redis.ts # Redis 客户端(P2 新增)
|
||||
│ ├─ jwt.ts # RS256 密钥加载(P2 新增)
|
||||
│ └─ env.ts # 环境变量(P2 扩展)
|
||||
│ ├─ database.ts # Drizzle 池 + getDbInstance + closeDb
|
||||
│ ├─ redis.ts # Redis 单例
|
||||
│ ├─ jwt.ts # RS256 密钥加载 + ttlToSeconds
|
||||
│ ├─ kafka.ts # Kafka producer 单例 + IAM_KAFKA_TOPICS
|
||||
│ └─ env.ts # Zod 校验环境变量
|
||||
├─ middleware/
|
||||
│ ├─ auth.middleware.ts # 保留(P2 仍不注册)
|
||||
│ └─ permission.guard.ts # 改造:DB 驱动 + 缓存
|
||||
│ ├─ auth.middleware.ts # 解析 x-user-* 头
|
||||
│ └─ permission.guard.ts # DB 驱动 + Redis 缓存权限校验
|
||||
├─ shared/
|
||||
│ ├─ errors/ # 已有
|
||||
│ ├─ health/ # 已有
|
||||
│ ├─ lifecycle/ # 改造:关闭顺序 HTTP→Kafka→Redis→DB
|
||||
│ └─ observability/ # 已有
|
||||
├─ app.module.ts # 改造:imports 新增 OutboxModule、CacheModule、JwksModule
|
||||
└─ main.ts # 改造:启动 OutboxRelayWorker
|
||||
│ ├─ cache/
|
||||
│ │ ├─ permission-cache.service.ts # Redis 权限缓存
|
||||
│ │ └─ token-blacklist.service.ts # Redis jti 黑名单
|
||||
│ ├─ errors/ # ApplicationError 层次 + GlobalErrorFilter
|
||||
│ ├─ health/ # /healthz + /readyz(5 依赖检查)
|
||||
│ ├─ lifecycle/ # 优雅停机(Kafka→Redis→DB)
|
||||
│ └─ observability/ # logger / metrics / tracer
|
||||
├─ app.module.ts # 根模块(IamModule + HealthModule + OutboxModule + APP_GUARD)
|
||||
└─ main.ts # Hybrid 启动(gRPC + HTTP)
|
||||
```
|
||||
|
||||
## 2. 领域模型
|
||||
@@ -154,12 +140,7 @@ classDiagram
|
||||
-name: string
|
||||
-status: UserStatus
|
||||
-dataScope: DataScope
|
||||
-createdAt: Date
|
||||
-updatedAt: Date
|
||||
+create(props) UserEntity$
|
||||
+rename(name) UserRenamedEvent
|
||||
+disable() UserDisabledEvent
|
||||
+changeDataScope(scope) UserDataScopeChangedEvent
|
||||
-passwordChangedAt: Date
|
||||
+verifyPassword(plain) bool
|
||||
}
|
||||
|
||||
@@ -168,8 +149,7 @@ classDiagram
|
||||
-name: string
|
||||
-description: string?
|
||||
-roleType: RoleType
|
||||
+create(props) RoleEntity$
|
||||
+rename(name) RoleRenamedEvent
|
||||
-level: int
|
||||
}
|
||||
|
||||
class Permission {
|
||||
@@ -185,24 +165,44 @@ classDiagram
|
||||
+viewportKey: string
|
||||
+label: string
|
||||
+route: string
|
||||
+sortOrder: string
|
||||
+level: ViewportLevel
|
||||
+sortOrder: int
|
||||
+requiredPermission: string?
|
||||
+componentConfig: string?
|
||||
}
|
||||
|
||||
class RefreshToken {
|
||||
+id: string
|
||||
+userId: string
|
||||
+tokenHash: string
|
||||
+jti: string
|
||||
+expiresAt: Date
|
||||
+revokedAt: Date?
|
||||
+isRevoked() bool
|
||||
+isExpired() bool
|
||||
}
|
||||
|
||||
class StudentGuardian {
|
||||
+id: string
|
||||
+studentId: string
|
||||
+guardianId: string
|
||||
+relation: string
|
||||
}
|
||||
|
||||
class AuditLog {
|
||||
+id: string
|
||||
+actorUserId: string?
|
||||
+action: string
|
||||
+resourceType: string
|
||||
+resourceId: string
|
||||
+beforeState: string?
|
||||
+afterState: string?
|
||||
+ipAddress: string?
|
||||
+userAgent: string?
|
||||
}
|
||||
|
||||
UserEntity "1" --> "many" RefreshToken : 拥有
|
||||
UserEntity "1" --> "many" StudentGuardian : 作为 guardian
|
||||
RoleEntity "1" --> "many" Permission : 通过 role_permissions
|
||||
RoleEntity "1" --> "many" RoleViewport : 配置
|
||||
UserEntity "1" --> "many" AuditLog : 作为 actor
|
||||
```
|
||||
|
||||
### 2.2 值对象(枚举)
|
||||
@@ -211,194 +211,111 @@ classDiagram
|
||||
enum UserStatus {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled",
|
||||
PENDING = "pending", // P2 新增:注册后待激活
|
||||
}
|
||||
|
||||
// DataScope 6 级(I8 裁决:SUBJECT 替代 DISTRICT)
|
||||
enum DataScope {
|
||||
SELF = "self", // L0
|
||||
CLASS = "class", // L1
|
||||
GRADE = "grade", // L2
|
||||
SCHOOL = "school", // L3
|
||||
DISTRICT = "district", // L4
|
||||
ALL = "all", // L5
|
||||
SELF = "self", // L0:仅自己
|
||||
SUBJECT = "subject", // L1:科目(教师所教科目+班级)
|
||||
CLASS = "class", // L2:班级(班主任所带班级)
|
||||
GRADE = "grade", // L3:年级
|
||||
SCHOOL = "school", // L4:全校
|
||||
ALL = "all", // L5:跨校(系统管理员)
|
||||
}
|
||||
|
||||
// 三层角色模型(§2.15)
|
||||
enum RoleType {
|
||||
// P2 新增:三层角色模型
|
||||
SYSTEM = "system", // 系统预设(admin/teacher/student/parent)
|
||||
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长)
|
||||
TEMPORARY = "temporary", // 临时授权(代课教师)
|
||||
SYSTEM = "system", // 系统预设(admin/teacher/student/parent),level=0
|
||||
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长),level=1
|
||||
TEMPORARY = "temporary", // 临时授权(代课教师),level=2
|
||||
}
|
||||
|
||||
enum ViewportLevel {
|
||||
ADMIN = "admin",
|
||||
TEACHER = "teacher",
|
||||
STUDENT = "student",
|
||||
PARENT = "parent",
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 聚合间通信
|
||||
|
||||
- **同服务内**:`IamService` 直接调用 `RbacService`、`PermissionCacheService`(NestJS DI)
|
||||
- **跨服务**:通过 Kafka 事件(Outbox 发布),不直接调用其他服务
|
||||
- **同服务内**:`IamService` 直接调用 `PermissionCacheService`、`TokenBlacklistService`、`JwksService`、`IamRepository`(NestJS DI)
|
||||
- **跨服务**:通过 Kafka 事件(Outbox 发布),不直接访问其他服务 DB
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 表清单(P2 目标态)
|
||||
### 3.1 表清单(10 张表)
|
||||
|
||||
#### 3.1.1 已有表(保留)
|
||||
|
||||
| 表名 | 用途 | 主键 | 唯一索引 |
|
||||
| ---------------------- | -------------------- | ----------------------------- | ----------------------- |
|
||||
| `iam_users` | 用户主表 | `id` (char36) | `email` |
|
||||
| `iam_roles` | 角色表 | `id` | `name` |
|
||||
| `iam_user_roles` | 用户-角色绑定 | `(userId, roleId)` 复合 | — |
|
||||
| `iam_permissions` | 权限点表 | `id` | `name` |
|
||||
| `iam_role_permissions` | 角色-权限映射 | `(roleId, permissionId)` 复合 | — |
|
||||
| `iam_refresh_tokens` | refresh token 持久化 | `id` | — |
|
||||
| `iam_role_viewports` | 角色-视口配置 | `id` | `(roleId, viewportKey)` |
|
||||
|
||||
#### 3.1.2 P2 新增表
|
||||
|
||||
| 表名 | 用途 | 主键 | 唯一索引 |
|
||||
| ------------------------------ | ------------------------------- | ---- | ----------------------- |
|
||||
| `iam_outbox` | Outbox 事件表(事务内写入) | `id` | — |
|
||||
| `iam_parent_student_relations` | 家长-学生关系表 | `id` | `(parentId, studentId)` |
|
||||
| `iam_user_sessions` | 用户会话记录(审计 + 强制下线) | `id` | `userId + deviceHash` |
|
||||
|
||||
> **注**:`class_subject_teachers` 表归属 core-edu(见 §8.1 决策点 8),不在 iam。
|
||||
|
||||
#### 3.1.3 P2 表结构定义
|
||||
|
||||
```typescript
|
||||
// iam_outbox:Outbox 事件表
|
||||
export const iamOutbox = mysqlTable(
|
||||
"iam_outbox",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
|
||||
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), // 'User' | 'Role'
|
||||
eventType: varchar("event_type", { length: 100 }).notNull(), // 'UserRegistered' | ...
|
||||
payload: text("payload").notNull(), // JSON 序列化
|
||||
topic: varchar("topic", { length: 100 }).notNull(), // 'edu.identity.user.created'
|
||||
status: mysqlEnum("status", ["pending", "published", "failed"])
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
retryCount: int("retry_count").notNull().default(0),
|
||||
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusIdx: index("idx_outbox_status").on(table.status), // relay 轮询用
|
||||
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateId),
|
||||
}),
|
||||
);
|
||||
|
||||
// iam_parent_student_relations:家长-学生关系
|
||||
export const parentStudentRelations = mysqlTable(
|
||||
"iam_parent_student_relations",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
parentId: char("parent_id", { length: 36 }).notNull(),
|
||||
studentId: char("student_id", { length: 36 }).notNull(),
|
||||
relation: varchar("relation", { length: 20 }).notNull(), // 'father' | 'mother' | 'guardian'
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
parentStudentUniq: uniqueIndex("uniq_parent_student").on(
|
||||
table.parentId,
|
||||
table.studentId,
|
||||
),
|
||||
studentIdx: index("idx_student").on(table.studentId),
|
||||
}),
|
||||
);
|
||||
|
||||
// iam_roles 表扩展:新增 role_type 字段
|
||||
// 在现有 iam_roles 表 ALTER ADD:
|
||||
// role_type ENUM('system','organization','temporary') NOT NULL DEFAULT 'system'
|
||||
// level INT NOT NULL DEFAULT 0 -- 三层优先级:system=0(最高) / organization=1 / temporary=2
|
||||
```
|
||||
| 表名 | 用途 | 主键 | 唯一索引 |
|
||||
| ----------------------- | ---------------------------- | ---- | ------------------------- |
|
||||
| `iam_users` | 用户主表 | `id` | `email` |
|
||||
| `iam_roles` | 角色表(role_type + level) | `id` | `name` |
|
||||
| `iam_user_roles` | 用户-角色绑定 | `id` | `(userId, roleId)` |
|
||||
| `iam_permissions` | 权限点表 | `id` | `name` |
|
||||
| `iam_role_permissions` | 角色-权限映射 | `id` | `(roleId, permissionId)` |
|
||||
| `iam_refresh_tokens` | refresh token(含 jti) | `id` | `jti` |
|
||||
| `iam_role_viewports` | 角色-视口配置(含 level) | `id` | `(roleId, viewportKey)` |
|
||||
| `iam_student_guardians` | 学生-家长关系(I6 裁决表名) | `id` | `(studentId, guardianId)` |
|
||||
| `iam_user_audit_log` | 审计日志(§5.5) | `id` | — |
|
||||
| `iam_password_history` | 密码重用限制 | `id` | — |
|
||||
|
||||
### 3.2 索引策略
|
||||
|
||||
| 表 | 索引 | 用途 |
|
||||
| ------------------------------ | -------------------------------------------------- | -------------------------------- |
|
||||
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录查询 |
|
||||
| `iam_user_roles` | INDEX(`userId`)、INDEX(`roleId`) | 按用户查角色、按角色查用户 |
|
||||
| `iam_role_permissions` | INDEX(`roleId`)、INDEX(`permissionId`) | 按角色查权限 |
|
||||
| `iam_refresh_tokens` | INDEX(`userId`)、INDEX(`tokenHash`) | 按用户查 token、按 hash 校验 |
|
||||
| `iam_role_viewports` | INDEX(`roleId`) | 按角色查视口 |
|
||||
| `iam_outbox` | INDEX(`status`)、INDEX(`aggregateId`) | relay 轮询 pending、按聚合查事件 |
|
||||
| `iam_parent_student_relations` | UNIQUE(`parentId`,`studentId`)、INDEX(`studentId`) | 防重、按学生查家长 |
|
||||
| 表 | 索引 | 用途 |
|
||||
| ----------------------- | ---------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录 |
|
||||
| `iam_user_roles` | UNIQUE(`userId`,`roleId`)、INDEX(`roleId`) | 防重、按角色查用户 |
|
||||
| `iam_role_permissions` | UNIQUE(`roleId`,`permissionId`)、INDEX(`permissionId`) | 防重、按权限查角色 |
|
||||
| `iam_refresh_tokens` | UNIQUE(`jti`)、INDEX(`userId`)、INDEX(`tokenHash`) | jti 黑名单、按用户、按 hash 校验 |
|
||||
| `iam_role_viewports` | UNIQUE(`roleId`,`viewportKey`) | 防重 |
|
||||
| `iam_student_guardians` | UNIQUE(`studentId`,`guardianId`)、INDEX(`guardianId`) | 防重、按家长查学生 |
|
||||
| `iam_user_audit_log` | INDEX(`actorUserId`)、INDEX(`resourceType`,`resourceId`)、INDEX(`createdAt`) | 按操作者、按资源、按时间查审计 |
|
||||
| `iam_password_history` | INDEX(`userId`,`createdAt`) | 按用户查密码历史 |
|
||||
|
||||
### 3.3 读写分离策略
|
||||
|
||||
- **写路径**:所有 Command 走 MySQL 主库(iam 独占库,无读写分离)
|
||||
- **读路径**:P2 暂不引入 ClickHouse 读模型(iam 读多写少但数据量小,MySQL 足够)
|
||||
- **缓存层**:`getEffectivePermissions` / `getUserViewports` 结果走 Redis 缓存(TTL 5min)
|
||||
- **写路径**:所有 Command 走 MySQL 主库(iam 独占库)
|
||||
- **读路径**:MySQL 直接读(iam 读多写少但数据量小,无需 ClickHouse 读模型)
|
||||
- **缓存层**:
|
||||
- `getEffectivePermissions` 结果走 Redis 缓存(TTL 5min),Key: `iam:perm:{userId}`
|
||||
- refresh token jti 黑名单走 Redis,Key: `iam:bl:{jti}`,TTL 与 refresh_token 剩余有效期对齐
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
### 4.1 REST API 完整清单(P2 目标态)
|
||||
### 4.1 REST API 清单
|
||||
|
||||
| Method | Path | 权限 | 请求体 / 参数 | 响应 | 说明 |
|
||||
| ------ | ------------------------------------------ | ----------------- | ---------------------------------- | ----------------- | -------------------------------------- |
|
||||
| POST | `/iam/register` | 公开 | `{email, password, name}` | `{user, tokens}` | 注册 + 自动分配 teacher 角色 |
|
||||
| POST | `/iam/login` | 公开 | `{email, password}` | `{user, tokens}` | 登录 |
|
||||
| POST | `/iam/refresh` | 公开 | `{refreshToken}` | `{tokens}` | 刷新令牌(轮换 + 旧 token 加入黑名单) |
|
||||
| POST | `/iam/logout` | `IAM_USER_READ` | `{refreshToken}` | `{success}` | 登出(refresh token 加黑名单) |
|
||||
| GET | `/iam/me` | `IAM_USER_READ` | — | `{user}` | 当前用户信息 |
|
||||
| GET | `/iam/viewports` | `IAM_USER_READ` | — | `{viewports[]}` | 当前用户视口(L1 导航) |
|
||||
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | — | `{permissions[]}` | 当前用户有效权限 |
|
||||
| GET | `/iam/.well-known/jwks.json` | 公开 | — | `{keys[]}` | RS256 公钥 JWK Set(Gateway 拉取) |
|
||||
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 角色列表 |
|
||||
| POST | `/iam/roles` | `IAM_ROLE_MANAGE` | `{name, description, roleType}` | `{role}` | 创建角色 |
|
||||
| PUT | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | `{name?, description?}` | `{role}` | 更新角色 |
|
||||
| DELETE | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除角色(系统角色禁止删) |
|
||||
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 权限点列表 |
|
||||
| GET | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 用户角色列表 |
|
||||
| POST | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | `{roleId}` | `{success}` | 给用户分配角色 |
|
||||
| DELETE | `/iam/users/:id/roles/:roleId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除用户角色(触发缓存失效) |
|
||||
| GET | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 角色权限列表 |
|
||||
| POST | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | `{permissionId}` | `{success}` | 给角色授予权限 |
|
||||
| DELETE | `/iam/roles/:id/permissions/:permissionId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除角色权限(触发缓存失效) |
|
||||
| GET | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | — | `{viewports[]}` | 角色视口列表 |
|
||||
| POST | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | `{viewportKey, label, route, ...}` | `{viewport}` | 创建视口配置 |
|
||||
| PUT | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | `{label?, route?, ...}` | `{viewport}` | 更新视口配置 |
|
||||
| DELETE | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除视口配置 |
|
||||
| GET | `/iam/users/:id/parents` | `IAM_USER_READ` | — | `{parents[]}` | 学生家长列表(家长-学生关系) |
|
||||
| POST | `/iam/users/:studentId/parents` | `IAM_ROLE_MANAGE` | `{parentId, relation}` | `{success}` | 绑定家长-学生关系 |
|
||||
| Method | Path | 权限 | 说明 |
|
||||
| ------ | ------------------------------- | ----------------- | -------------------------------------- |
|
||||
| POST | `/v1/iam/register` | 公开 | 注册 |
|
||||
| POST | `/v1/iam/login` | 公开 | 登录 |
|
||||
| POST | `/v1/iam/refresh` | 公开 | 刷新令牌(轮换 + 旧 jti 加黑名单) |
|
||||
| POST | `/v1/iam/logout` | `IAM_USER_READ` | 登出(refresh token 加黑名单) |
|
||||
| GET | `/v1/iam/me` | `IAM_USER_READ` | 当前用户信息 |
|
||||
| GET | `/v1/iam/viewports` | `IAM_USER_READ` | 当前用户视口 |
|
||||
| GET | `/v1/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限 |
|
||||
| GET | `/v1/iam/children` | `IAM_USER_READ` | 家长的孩子列表 |
|
||||
| GET | `/v1/iam/roles` | `IAM_ROLE_MANAGE` | 角色列表 |
|
||||
| GET | `/v1/iam/permissions` | `IAM_ROLE_MANAGE` | 权限点列表 |
|
||||
| GET | `/v1/iam/audit` | `IAM_AUDIT_READ` | 审计日志(支持 actor/resource 查询) |
|
||||
| GET | `/v1/iam/.well-known/jwks.json` | 公开 | RS256 公钥 JWK Set(Gateway 拉取验签) |
|
||||
|
||||
### 4.2 请求/响应结构示例
|
||||
### 4.2 gRPC API 清单(12 RPC,package `next_edu_cloud.iam.v1`)
|
||||
|
||||
```typescript
|
||||
// 注册响应
|
||||
interface RegisterResponse {
|
||||
success: true;
|
||||
data: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[]; // ['teacher']
|
||||
permissions: string[]; // ['IAM_USER_READ', 'CLASSES_READ', ...]
|
||||
dataScope: "self" | "class" | "grade" | "school" | "district" | "all";
|
||||
};
|
||||
tokens: {
|
||||
accessToken: string; // RS256 签名,15min
|
||||
refreshToken: string; // RS256 签名,7day
|
||||
expiresIn: 900; // 秒
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// JWK Set 响应(RS256 公钥暴露)
|
||||
interface JwkSet {
|
||||
keys: Array<{
|
||||
kty: "RSA";
|
||||
use: "sig";
|
||||
alg: "RS256";
|
||||
kid: string; // 密钥 ID(支持轮换)
|
||||
n: string; // modulus base64url
|
||||
e: string; // exponent base64url
|
||||
}>;
|
||||
}
|
||||
```
|
||||
| RPC | 请求 | 响应 | 说明 |
|
||||
| ------------------------- | ------------------------- | ---------------------- | -------------------------- |
|
||||
| `Register` | `{email, password, name}` | `{user, tokens}` | 注册 |
|
||||
| `Login` | `{email, password}` | `{user, tokens}` | 登录 |
|
||||
| `RefreshToken` | `{refreshToken}` | `{tokens}` | 刷新令牌 |
|
||||
| `Logout` | `{refreshToken, userId}` | `{success}` | 登出 |
|
||||
| `GetUserInfo` | `{userId}` | `UserInfo` | 查询用户信息 |
|
||||
| `BatchGetUsers` | `{userIds[]}` | `{users[]}` | 批量查询用户(BFF 聚合用) |
|
||||
| `GetEffectivePermissions` | `{userId}` | `{permissions[]}` | 用户有效权限 |
|
||||
| `GetEffectiveAccess` | `{userId, permission}` | `{allowed, dataScope}` | 用户对某权限的有效访问判定 |
|
||||
| `GetEffectiveDataScope` | `{userId}` | `{dataScope}` | 用户数据范围 |
|
||||
| `GetViewports` | `{userId}` | `{viewports[]}` | 用户视口 |
|
||||
| `GetPublicKey` | — | `{kid, alg, pem}` | 公钥拉取(备用 JWKS) |
|
||||
| `GetChildrenByParent` | `{parentId}` | `{children[]}` | 家长的孩子列表 |
|
||||
|
||||
### 4.3 JWT Payload(RS256 签发)
|
||||
|
||||
@@ -411,70 +328,85 @@ interface JwtPayload {
|
||||
type: "access" | "refresh";
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
iss: "next-edu-cloud"; // 签发者
|
||||
aud: "next-edu-cloud"; // 受众
|
||||
jti: string; // JWT ID(用于黑名单)
|
||||
iss: "next-edu-cloud";
|
||||
aud: "next-edu-cloud";
|
||||
jti: string; // JWT ID(仅 refresh token,用于黑名单)
|
||||
kid: string; // 密钥 ID(header,支持 JWKS 轮换)
|
||||
}
|
||||
```
|
||||
|
||||
- **access_token**:TTL 15min(`ACCESS_TOKEN_TTL`),RS256 签名
|
||||
- **refresh_token**:TTL 7day(`REFRESH_TOKEN_TTL_DAYS`),RS256 签名,含 jti
|
||||
- **密钥**:本地文件(`IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`),启动时一次性加载
|
||||
|
||||
## 5. 事件设计
|
||||
|
||||
### 5.1 我发布的领域事件
|
||||
|
||||
| 事件 | 触发时机 | Topic | 消费者动作 |
|
||||
| ----------------- | ------------------------------ | -------------------------------- | -------------------------------------------------- |
|
||||
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu 初始化默认班级关联;msg 发欢迎通知 |
|
||||
| `UserUpdated` | 用户信息变更(name/dataScope) | `edu.identity.user.updated` | core-edu 同步用户快照;msg 通知 |
|
||||
| `UserDisabled` | 用户禁用/注销 | `edu.identity.user.deleted` | core-edu 解除关联;msg 通知;push-gateway 强制下线 |
|
||||
| `UserRoleChanged` | 用户角色绑定变更 | `edu.identity.user.role_changed` | 自身 Redis 缓存失效;msg 审计日志 |
|
||||
| `RoleCreated` | 角色创建 | `edu.identity.role.created` | msg 审计(仅管理端关注) |
|
||||
| `RoleUpdated` | 角色权限变更 | `edu.identity.role.updated` | 所有该角色用户的缓存失效;msg 审计 |
|
||||
| 事件 | 触发时机 | Topic | 消费者 |
|
||||
| -------------- | ------------------------------------ | ----------------------- | -------------- |
|
||||
| `UserCreated` | 注册成功 | `edu.iam.user.events` | core-edu / msg |
|
||||
| `AuditCreated` | 关键操作(登录/角色变更/密码修改等) | `edu.iam.audit.created` | data-ana |
|
||||
| `RoleChanged` | 角色绑定变更 | `edu.iam.role.events` | msg / 缓存失效 |
|
||||
|
||||
### 5.2 事件 Schema(建议 coord 在 shared-proto 中统一定义)
|
||||
### 5.2 事件 Schema(events.proto)
|
||||
|
||||
```protobuf
|
||||
// 建议在 packages/shared-proto/proto/events.proto 新增:
|
||||
message UserEvent {
|
||||
string event_id = 1; // UUID,幂等去重
|
||||
string aggregate_id = 2; // userId
|
||||
string event_type = 3; // 'UserRegistered' | 'UserUpdated' | ...
|
||||
int64 occurred_at = 4; // 发生时间戳(ms)
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string user_id = 5;
|
||||
string email = 6;
|
||||
string name = 7;
|
||||
repeated string roles = 8;
|
||||
string data_scope = 9;
|
||||
string action = 10; // 'created' | 'updated' | 'disabled' | 'role_changed'
|
||||
map<string, string> metadata = 11; // trace_id 等
|
||||
string action = 10;
|
||||
map<string, string> metadata = 11;
|
||||
}
|
||||
|
||||
message RoleEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2; // roleId
|
||||
string aggregate_id = 2;
|
||||
string event_type = 3;
|
||||
int64 occurred_at = 4;
|
||||
string role_id = 5;
|
||||
string role_name = 6;
|
||||
string action = 7; // 'created' | 'updated' | 'deleted'
|
||||
string action = 7;
|
||||
map<string, string> metadata = 8;
|
||||
}
|
||||
|
||||
message AuditEvent {
|
||||
string event_id = 1;
|
||||
string actor_user_id = 2;
|
||||
string action = 3;
|
||||
string resource_type = 4;
|
||||
string resource_id = 5;
|
||||
int64 occurred_at = 6;
|
||||
string ip_address = 7;
|
||||
map<string, string> metadata = 8;
|
||||
}
|
||||
```
|
||||
|
||||
> **需 coord 在 shared-proto/events.proto 中统一定义**,iam 只负责填充字段并写入 outbox。
|
||||
|
||||
### 5.3 我消费的事件
|
||||
|
||||
- **当前**:无
|
||||
- **未来**:不主动消费业务事件(iam 是权限中枢,单向发布)
|
||||
无。iam 是权限中枢,单向发布,不消费业务事件。
|
||||
|
||||
### 5.4 Outbox 实现策略
|
||||
|
||||
- **shared-ts OutboxModule**(I4 裁决):`OutboxModule.forRoot({ config, db, kafkaProducer })`
|
||||
- **表名**:`iam_outbox`
|
||||
- **topic**:`edu.iam.user.events`(默认)/ `edu.iam.role.events` / `edu.iam.audit.created`
|
||||
- **轮询间隔**:1000ms,批量 20 条,最大重试 5 次,退避 1000ms
|
||||
- **幂等性**:producer `idempotent=true` + `transactionalId: "iam-tx"`,consumer 基于 `event_id` 去重
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Ctl as Controller
|
||||
participant Svc as IamService
|
||||
participant DB as MySQL
|
||||
participant Outbox as iam_outbox 表
|
||||
participant Pub as OutboxPublisher
|
||||
participant Relay as OutboxRelayWorker
|
||||
participant Kafka as Kafka
|
||||
|
||||
@@ -482,77 +414,37 @@ sequenceDiagram
|
||||
Svc->>DB: BEGIN TX
|
||||
Svc->>DB: INSERT iam_users
|
||||
Svc->>DB: INSERT iam_user_roles
|
||||
Svc->>Outbox: INSERT event (status=pending)
|
||||
Svc->>DB: INSERT iam_user_audit_log
|
||||
Svc->>Pub: publish(UserCreated + AuditCreated)
|
||||
Pub->>DB: INSERT iam_outbox
|
||||
Svc->>DB: COMMIT TX
|
||||
Svc-->>Ctl: {user, tokens}
|
||||
|
||||
loop 每 100ms 轮询
|
||||
Relay->>Outbox: SELECT * WHERE status='pending' LIMIT 100
|
||||
Outbox-->>Relay: events[]
|
||||
loop 每 1000ms 轮询
|
||||
Relay->>DB: SELECT pending LIMIT 20
|
||||
Relay->>Kafka: produce(topic, payload)
|
||||
Kafka-->>Relay: ack
|
||||
Relay->>Outbox: UPDATE status='published', published_at=NOW()
|
||||
Relay->>DB: UPDATE status='published'
|
||||
end
|
||||
```
|
||||
|
||||
**Relay Worker 实现**:
|
||||
|
||||
- 独立 `@Injectable()` 服务,`OnModuleInit` 启动轮询
|
||||
- 每 100ms 查询 `status='pending'` 的事件,批量投递 Kafka
|
||||
- 投递失败重试 3 次,超过后标记 `status='failed'`,记录日志
|
||||
- Kafka 未启动时不阻塞主服务(try/catch + 日志警告)
|
||||
|
||||
## 6. 横切关注点对齐清单
|
||||
|
||||
### 6.1 权限装饰器(所有端点及对应权限常量)
|
||||
### 6.1 权限装饰器
|
||||
|
||||
| 端点 | 权限常量 |
|
||||
| ----------------------------------------------- | ----------------- |
|
||||
| POST /iam/register | 公开(无装饰器) |
|
||||
| POST /iam/login | 公开 |
|
||||
| POST /iam/refresh | 公开 |
|
||||
| GET /iam/.well-known/jwks.json | 公开 |
|
||||
| POST /iam/logout | `IAM_USER_READ` |
|
||||
| GET /iam/me | `IAM_USER_READ` |
|
||||
| GET /iam/viewports | `IAM_USER_READ` |
|
||||
| GET /iam/permissions/effective | `IAM_USER_READ` |
|
||||
| GET /iam/users/:id/parents | `IAM_USER_READ` |
|
||||
| GET /iam/roles | `IAM_ROLE_MANAGE` |
|
||||
| POST /iam/roles | `IAM_ROLE_MANAGE` |
|
||||
| PUT /iam/roles/:id | `IAM_ROLE_MANAGE` |
|
||||
| DELETE /iam/roles/:id | `IAM_ROLE_MANAGE` |
|
||||
| GET /iam/permissions | `IAM_ROLE_MANAGE` |
|
||||
| GET /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
|
||||
| POST /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
|
||||
| DELETE /iam/users/:id/roles/:roleId | `IAM_ROLE_MANAGE` |
|
||||
| GET /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
|
||||
| POST /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
|
||||
| DELETE /iam/roles/:id/permissions/:permissionId | `IAM_ROLE_MANAGE` |
|
||||
| GET /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
|
||||
| POST /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
|
||||
| PUT /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
|
||||
| DELETE /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
|
||||
| POST /iam/users/:studentId/parents | `IAM_ROLE_MANAGE` |
|
||||
|
||||
**权限常量清单**(P2 完整化):
|
||||
每个 Controller 方法用 `@RequirePermission()` 装饰器声明权限点。权限点常量:
|
||||
|
||||
```typescript
|
||||
export const Permissions = {
|
||||
// 用户管理
|
||||
IAM_USER_CREATE: "IAM_USER_CREATE",
|
||||
IAM_USER_READ: "IAM_USER_READ",
|
||||
IAM_USER_UPDATE: "IAM_USER_UPDATE",
|
||||
IAM_USER_DELETE: "IAM_USER_DELETE",
|
||||
// 角色管理
|
||||
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE",
|
||||
// 视口管理
|
||||
IAM_VIEWPORT_MANAGE: "IAM_VIEWPORT_MANAGE",
|
||||
// 家长-学生关系管理
|
||||
IAM_RELATION_MANAGE: "IAM_RELATION_MANAGE",
|
||||
IAM_USER_READ: "iam:user:read",
|
||||
IAM_USER_MANAGE: "iam:user:manage",
|
||||
IAM_ROLE_MANAGE: "iam:role:manage",
|
||||
IAM_AUDIT_READ: "iam:audit:read",
|
||||
IAM_VIEWPORT_READ: "iam:viewport:read",
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 6.2 错误码清单(带前缀)
|
||||
### 6.2 错误码清单
|
||||
|
||||
| 错误码 | HTTP | 触发条件 |
|
||||
| ----------------------- | ---- | ---------------------------------------------------- |
|
||||
@@ -562,157 +454,109 @@ export const Permissions = {
|
||||
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
|
||||
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
|
||||
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
|
||||
| `IAM_RATE_LIMITED` | 429 | 登录失败次数过多(P2 可选,限流在 Gateway) |
|
||||
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
|
||||
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
|
||||
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
|
||||
|
||||
### 6.3 Logger 初始化位置与配置
|
||||
### 6.3 可观测性
|
||||
|
||||
- **位置**:`src/shared/observability/logger.ts`(已有)
|
||||
- **配置**:pino,`base: { service: 'iam', version: '0.1.0' }`,`level: env.LOG_LEVEL`
|
||||
- **P2 新增**:日志中注入 `traceId`(从 `x-request-id` 头读取,OTel auto-instrumentation 已覆盖)
|
||||
- **日志**:pino,`base: { service: 'iam', version: '0.2.0' }`,`level: env.LOG_LEVEL`,注入 traceId
|
||||
- **指标**:prom-client,`/metrics` 端点
|
||||
- `iam_login_attempts_total`、`iam_login_duration_seconds`
|
||||
- `iam_jwt_issued_total`(type=access/refresh)
|
||||
- `iam_permission_cache_hits_total` / `iam_permission_cache_misses_total`
|
||||
- `iam_outbox_pending`、`iam_outbox_publish_duration_seconds`
|
||||
- **链路**:OpenTelemetry SDK + OTLP exporter,serviceName: `iam`
|
||||
|
||||
### 6.4 Metrics 指标清单
|
||||
### 6.4 健康检查
|
||||
|
||||
| 指标名 | 类型 | 标签 | 描述 |
|
||||
| ------------------------------------- | --------- | ------------------------ | ------------------------------ |
|
||||
| `iam_requests_total` | Counter | method, endpoint, status | 请求总数(已有) |
|
||||
| `iam_request_duration_seconds` | Histogram | method, endpoint | 请求延迟(已有) |
|
||||
| `iam_login_attempts_total` | Counter | result(success/failure) | 登录尝试次数(P2 新增) |
|
||||
| `iam_login_duration_seconds` | Histogram | — | 登录耗时(P2 新增) |
|
||||
| `iam_jwt_issued_total` | Counter | type(access/refresh) | JWT 签发次数(P2 新增) |
|
||||
| `iam_permission_cache_hits_total` | Counter | — | 权限缓存命中(P2 新增) |
|
||||
| `iam_permission_cache_misses_total` | Counter | — | 权限缓存未命中(P2 新增) |
|
||||
| `iam_outbox_pending` | Gauge | — | Outbox 待投递事件数(P2 新增) |
|
||||
| `iam_outbox_publish_duration_seconds` | Histogram | — | Outbox 投递耗时(P2 新增) |
|
||||
- **`/healthz`**:liveness,仅返回进程存活
|
||||
- 响应:`{ status: 'ok', service: 'iam', timestamp: ISO }`
|
||||
- **`/readyz`**:readiness,5 依赖检查
|
||||
- DB:`SELECT 1`
|
||||
- Redis:`ping`
|
||||
- Kafka:producer 实例存在且已连接
|
||||
- JWKS:密钥文件可读
|
||||
- gRPC:进程内 microservice 存在
|
||||
- 失败:HTTP 503,响应体含失败项详情
|
||||
|
||||
### 6.5 Tracer 初始化位置
|
||||
|
||||
- **位置**:`src/shared/observability/tracer.ts`(已有)
|
||||
- **配置**:NodeSDK + OTLP HTTP exporter,`serviceName: 'iam'`
|
||||
- **P2 保持**:auto-instrumentations 覆盖 HTTP/Express/Drizzle(mysql2)
|
||||
|
||||
### 6.6 /healthz 检查逻辑
|
||||
|
||||
- **端点**:`GET /healthz`
|
||||
- **逻辑**:仅返回进程存活,不检查依赖
|
||||
- **响应**:`{ status: 'ok', service: 'iam', timestamp: ISO }`
|
||||
|
||||
### 6.7 /readyz 检查逻辑(P2 改造)
|
||||
|
||||
- **端点**:`GET /readyz`
|
||||
- **逻辑**(P2 新增 Redis + Kafka 检查):
|
||||
```typescript
|
||||
async readiness() {
|
||||
const checks = await Promise.allSettled([
|
||||
this.checkDb(), // db.execute(sql`SELECT 1`)
|
||||
this.checkRedis(), // redis.ping()
|
||||
this.checkKafka(), // kafka.admin().listTopics()(轻量探活)
|
||||
]);
|
||||
const allOk = checks.every(r => r.status === 'fulfilled');
|
||||
if (!allOk) throw 503;
|
||||
return { status: 'ok', service: 'iam', timestamp, checks };
|
||||
}
|
||||
```
|
||||
- **失败**:HTTP 503,响应体含失败项详情
|
||||
|
||||
### 6.8 优雅关闭顺序(P2 改造)
|
||||
### 6.5 优雅关闭顺序
|
||||
|
||||
```typescript
|
||||
async onApplicationShutdown(signal?: string) {
|
||||
// 1. 停止接收新请求(NestJS 自动)
|
||||
// 2. 停止 OutboxRelayWorker(停止轮询)
|
||||
await this.relayWorker.stop();
|
||||
// 2. 停止 OutboxRelayWorker(shared-ts 自动)
|
||||
// 3. 关闭 Kafka producer
|
||||
await this.kafkaProducer.disconnect();
|
||||
await closeKafkaProducer();
|
||||
// 4. 关闭 Redis 连接
|
||||
await this.redisClient.quit();
|
||||
await closeRedis();
|
||||
// 5. 关闭 MySQL 连接池
|
||||
await closeDb();
|
||||
// 6. 关闭 Tracer
|
||||
await shutdownTracer();
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 与其他模块的交互点(契约清单)
|
||||
|
||||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
|
||||
| ------ | ----------- | ----- | ------------------------------------------- | -------------------------------- |
|
||||
| 被调用 | api-gateway | HTTP | `POST /iam/register` 等 | Gateway 反向代理 `/api/v1/iam/*` |
|
||||
| 被调用 | teacher-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | BFF 聚合用户身份与视口 |
|
||||
| 被调用 | student-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | 同上(P3) |
|
||||
| 被调用 | parent-bff | HTTP | `GET /iam/me`、`GET /iam/users/:id/parents` | 同上 + 家长-学生关系(P4) |
|
||||
| 被调用 | api-gateway | HTTP | `GET /iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
|
||||
| 发布 | — | Kafka | `edu.identity.user.created` | core-edu / msg 消费 |
|
||||
| 发布 | — | Kafka | `edu.identity.user.updated` | core-edu / msg 消费 |
|
||||
| 发布 | — | Kafka | `edu.identity.user.deleted` | core-edu / msg 消费 |
|
||||
| 发布 | — | Kafka | `edu.identity.user.role_changed` | 自身缓存失效 / msg 审计 |
|
||||
| 发布 | — | Kafka | `edu.identity.role.created` | msg 审计 |
|
||||
| 发布 | — | Kafka | `edu.identity.role.updated` | 自身缓存失效 / msg 审计 |
|
||||
| 消费 | — | — | — | iam 不消费外部事件 |
|
||||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
|
||||
| ------ | ----------- | ----- | ------------------------------------------------- | ------------------------------- |
|
||||
| 被调用 | api-gateway | HTTP | `POST /v1/iam/register` 等 | Gateway 反向代理 `/iam/v1/*` |
|
||||
| 被调用 | api-gateway | HTTP | `GET /v1/iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
|
||||
| 被调用 | teacher-bff | gRPC | `GetUserInfo`、`BatchGetUsers`、`GetViewports` 等 | BFF 聚合用户身份与视口 |
|
||||
| 被调用 | student-bff | gRPC | 同上 | 同上 |
|
||||
| 被调用 | parent-bff | gRPC | `GetChildrenByParent` | 家长视角聚合孩子信息 |
|
||||
| 发布 | — | Kafka | `edu.iam.user.events` | core-edu / msg 消费 |
|
||||
| 发布 | — | Kafka | `edu.iam.role.events` | msg / 缓存失效 |
|
||||
| 发布 | — | Kafka | `edu.iam.audit.created` | data-ana 消费 |
|
||||
|
||||
### 7.1 端口分配
|
||||
|
||||
| 服务 | 端口 | 备注 |
|
||||
| -------- | ----- | ------------------------------------------------------------ |
|
||||
| iam HTTP | 3002 | 已有,REST 入口 |
|
||||
| iam gRPC | 50052 | P3 引入(与 core-edu 50053 等区分,需 coord 全局端口表确认) |
|
||||
| 服务 | 端口 | 备注 |
|
||||
| -------- | ----- | -------------------- |
|
||||
| iam HTTP | 3002 | REST 入口 |
|
||||
| iam gRPC | 50052 | gRPC 入口(I1 裁决) |
|
||||
|
||||
### 7.2 Topic 命名(遵循 004 §7.2)
|
||||
### 7.2 Topic 命名
|
||||
|
||||
- `edu.identity.user.created`
|
||||
- `edu.identity.user.updated`
|
||||
- `edu.identity.user.deleted`
|
||||
- `edu.identity.user.role_changed`
|
||||
- `edu.identity.role.created`
|
||||
- `edu.identity.role.updated`
|
||||
- `edu.iam.user.events`
|
||||
- `edu.iam.role.events`
|
||||
- `edu.iam.audit.created`
|
||||
|
||||
> **需 coord 在全局 Topic 表中登记**,避免与 core-edu 的 `edu.identity.user.created` 冲突(004 §7.2 已记录 iam 为生产者)。
|
||||
## 8. 仲裁裁决对齐
|
||||
|
||||
## 8. 风险与假设
|
||||
### 8.1 I1-I8 裁决
|
||||
|
||||
### 8.1 架构决策点(需 coord 仲裁)
|
||||
| 裁决 | 内容 | 实现位置 |
|
||||
| ---- | ------------------------------------ | ------------------------------------------------- |
|
||||
| I1 | 双入口(REST + gRPC),gRPC 50052 | main.ts Hybrid app + iam.grpc.controller.ts |
|
||||
| I2 | JWT RS256 本地文件密钥 | config/jwt.ts + env IAM_PRIVATE/PUBLIC_KEY_PATH |
|
||||
| I3 | DB 驱动 + Redis 缓存 PermissionGuard | permission.guard.ts + permission-cache.service.ts |
|
||||
| I4 | shared-ts OutboxModule 接入 | app.module.ts OutboxModule.forRoot |
|
||||
| I5 | /iam/v1/* 前缀 | iam.controller.ts @Controller("v1/iam") |
|
||||
| I6 | iam_student_guardians 表名 | iam.schema.ts + iam-init.sql |
|
||||
| I7 | jti 黑名单 | token-blacklist.service.ts |
|
||||
| I8 | DataScope SUBJECT 替代 DISTRICT | iam.schema.ts DataScope enum |
|
||||
|
||||
| # | 决策点 | 我的建议 | 风险 |
|
||||
| --- | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 1 | RS256 密钥管理 | P2 本地文件 `IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`,P6 迁 Vault | 密钥文件权限管理;容器挂载 |
|
||||
| 2 | 公钥暴露端点 | `GET /iam/.well-known/jwks.json`(JWK Set 标准) | Gateway 需实现 JWK 解析 |
|
||||
| 3 | 权限缓存失效 | 角色变更时本地主动 `DEL iam:perms:{userId}` + 发事件 | 缓存与 DB 短暂不一致(< 5min TTL) |
|
||||
| 4 | Outbox 实现 | iam 自建轻量 Outbox(P2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
|
||||
| 5 | gRPC 引入时机 | P2 仅 REST,P3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTP,P3 改 gRPC 需协调 ai03 |
|
||||
| 6 | DataScope 枚举 | schema 用字符串枚举,proto 用数值枚举 L0-L5 映射 | 跨层映射需文档化 |
|
||||
| 7 | `parent_student_relations` 归属 | 归 iam(身份关系优先) | core-edu 查询家长需走 iam API |
|
||||
| 8 | `class_subject_teachers` 归属 | 归 core-edu(教学组织数据) | iam 只存 userId + 角色,不存任教关系 |
|
||||
| 9 | PermissionGuard 改造 | DB 驱动 + Redis 缓存,废弃本地 ROLE_PERMISSIONS map | 性能:每次请求多一次缓存查询 |
|
||||
| 10 | AuthMiddleware 注册 | P2 仍不注册,Controller 直读 header(与 Gateway 透传策略一致) | 多 Controller 重复读 header 代码 |
|
||||
### 8.2 总裁裁决
|
||||
|
||||
### 8.2 技术风险
|
||||
| 裁决 | 内容 | 实现位置 |
|
||||
| ----- | --------------------------------------------- | -------------------------------------------- |
|
||||
| §2.15 | 三层角色模型(system/organization/temporary) | iam.schema.ts RoleType + iam_roles.level |
|
||||
| §2.16 | 双入口策略(同一 IamService) | iam.service.ts REST 与 gRPC 共用 |
|
||||
| §3.2 | AuthMiddleware 注册 | app.module.ts configure() |
|
||||
| §5.5 | 审计日志(iam_user_audit_log + AuditEvent) | iam.schema.ts + iam.service.ts writeAuditLog |
|
||||
|
||||
## 9. 技术风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
|
||||
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB,不抛错 |
|
||||
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
|
||||
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 + 定期轮换(P6) |
|
||||
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 事件驱动失效 + 短 TTL |
|
||||
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天)或归档到 ClickHouse |
|
||||
|
||||
### 8.3 假设
|
||||
|
||||
- 假设 coord 在 shared-proto/events.proto 中统一定义 `UserEvent` / `RoleEvent` message(iam 只填充字段)
|
||||
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验(ai01 负责)
|
||||
- 假设 teacher-bff 仍走 HTTP 调用 iam(ai03 负责,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-edu,pending-features §P2 写在 iam)
|
||||
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 |
|
||||
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 短 TTL |
|
||||
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天) |
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai02 (iam-module)
|
||||
**Branch**: main(单仓库并行模式,见 ai-allocation §9.1)
|
||||
**Coordinator**: coord-ai
|
||||
**模块**: iam-service(@edu/iam-service v0.2.0)
|
||||
**分支**: feat-implement-iam-module
|
||||
**入口**: HTTP 3002 + gRPC 50052
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@edu/iam-service",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,23 +12,29 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/shared-ts": "workspace:*",
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.0",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/microservices": "^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",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"kafkajs": "^2.2.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"pino": "^9.4.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",
|
||||
"rxjs": "^7.8.0"
|
||||
"rxjs": "^7.8.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
|
||||
@@ -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 { IamModule } from "./iam/iam.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware.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-* 头部解析用户身份)
|
||||
* - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存,I3 裁决)
|
||||
*/
|
||||
@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: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule, OnModuleInit {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 连接 Kafka producer(Outbox 投递前置依赖)
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,23 @@ export function getDb(): MySql2Database {
|
||||
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> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('3002'),
|
||||
// HTTP
|
||||
PORT: z.string().default("3002"),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
JWT_SECRET: z.string(),
|
||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
||||
JWT_AUDIENCE: z.string().default('next-edu-cloud'),
|
||||
|
||||
// Redis(缓存 + token 黑名单)
|
||||
REDIS_URL: z.string().url(),
|
||||
|
||||
// JWT RS256(president §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"),
|
||||
|
||||
// Kafka(Outbox 投递)
|
||||
KAFKA_BROKERS: z.string(),
|
||||
KAFKA_CLIENT_ID: z.string().default("iam-service"),
|
||||
|
||||
// gRPC server(I1 裁决:端口 50052)
|
||||
GRPC_PORT: z.string().default("50052"),
|
||||
|
||||
// 可观测性
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
LOG_LEVEL: z
|
||||
.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>;
|
||||
@@ -17,8 +41,11 @@ export type Env = z.infer<typeof envSchema>;
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
console.error(
|
||||
"❌ Invalid environment variables:",
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
throw new Error("Invalid environment configuration");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
54
services/iam/src/config/jwt.ts
Normal file
54
services/iam/src/config/jwt.ts
Normal 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 开销。
|
||||
* kid(Key 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];
|
||||
}
|
||||
44
services/iam/src/config/kafka.ts
Normal file
44
services/iam/src/config/kafka.ts
Normal 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;
|
||||
24
services/iam/src/config/redis.ts
Normal file
24
services/iam/src/config/redis.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
40
services/iam/src/iam/audit.controller.ts
Normal file
40
services/iam/src/iam/audit.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
import { Body, Controller, Get, Post, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { IamService } from "./iam.service.js";
|
||||
import type { TokenPair, UserInfo } from "./iam.service.js";
|
||||
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
|
||||
import type {
|
||||
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 {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
|
||||
@Controller("iam")
|
||||
/**
|
||||
* IAM REST Controller(双入口之 REST 侧)。
|
||||
*
|
||||
* 路径前缀:/v1/iam(I7 裁决:REST 路径统一加 /v1 前缀)
|
||||
* gateway 路由:/iam/v1/* → iam /v1/iam/*(透传不改路径)
|
||||
*
|
||||
* 公开端点:register / login / refresh / jwks(JwksController)
|
||||
* 鉴权端点:me / viewports / permissions/effective / children / logout
|
||||
*/
|
||||
@Controller("v1/iam")
|
||||
export class IamController {
|
||||
constructor(private readonly service: IamService) {}
|
||||
|
||||
// 公开端点:注册,不设权限校验
|
||||
@Post("register")
|
||||
async register(
|
||||
@Body() body: unknown,
|
||||
@@ -23,7 +41,6 @@ export class IamController {
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
// 公开端点:登录,不设权限校验
|
||||
@Post("login")
|
||||
async login(
|
||||
@Body() body: unknown,
|
||||
@@ -33,7 +50,6 @@ export class IamController {
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
// 公开端点:刷新令牌,不设权限校验
|
||||
@Post("refresh")
|
||||
async refresh(
|
||||
@Body() body: unknown,
|
||||
@@ -43,15 +59,70 @@ export class IamController {
|
||||
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")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async me(@Req() req: Request): Promise<{ success: true; data: UserInfo }> {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
async me(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: UserInfo }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
throw new UnauthorizedError("Missing user identity");
|
||||
}
|
||||
const user = await this.service.getUserInfo(userId);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -15,5 +15,10 @@ export const refreshTokenSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
export const logoutSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
export type RegisterDto = z.infer<typeof registerSchema>;
|
||||
export type LoginDto = z.infer<typeof loginSchema>;
|
||||
export type LogoutDto = z.infer<typeof logoutSchema>;
|
||||
|
||||
159
services/iam/src/iam/iam.grpc.controller.ts
Normal file
159
services/iam/src/iam/iam.grpc.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IamController } from "./iam.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 { 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({
|
||||
controllers: [IamController, RbacController],
|
||||
providers: [IamService, IamRepository],
|
||||
controllers: [IamController, RbacController, AuditController, JwksController],
|
||||
providers: [
|
||||
IamService,
|
||||
IamRepository,
|
||||
JwksService,
|
||||
PermissionCacheService,
|
||||
TokenBlacklistService,
|
||||
IamGrpcController,
|
||||
],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
users,
|
||||
@@ -8,16 +8,36 @@ import {
|
||||
rolePermissions,
|
||||
refreshTokens,
|
||||
roleViewports,
|
||||
studentGuardians,
|
||||
userAuditLog,
|
||||
passwordHistory,
|
||||
} from "./iam.schema.js";
|
||||
import type {
|
||||
User,
|
||||
Role,
|
||||
Permission,
|
||||
RoleViewport,
|
||||
StudentGuardian,
|
||||
AuditLog,
|
||||
DataScope,
|
||||
} from "./iam.schema.js";
|
||||
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
|
||||
import { DatabaseError } from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* IAM 数据访问层。
|
||||
*
|
||||
* 覆盖:用户 CRUD、角色/权限查询、刷新令牌管理、视口查询、
|
||||
* 学生-家长关系、审计日志、密码历史。
|
||||
*/
|
||||
export class IamRepository {
|
||||
// ============ 用户 ============
|
||||
|
||||
async createUser(data: {
|
||||
id: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
name: string;
|
||||
dataScope?: DataScope;
|
||||
}): Promise<User> {
|
||||
const db = getDb();
|
||||
await db.insert(users).values(data);
|
||||
@@ -43,6 +63,27 @@ export class IamRepository {
|
||||
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[]> {
|
||||
const db = getDb();
|
||||
const result = await db
|
||||
@@ -53,6 +94,31 @@ export class IamRepository {
|
||||
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[]> {
|
||||
const db = getDb();
|
||||
const userRoleRows = await db
|
||||
@@ -72,6 +138,13 @@ export class IamRepository {
|
||||
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[]> {
|
||||
const db = getDb();
|
||||
const userRoleRows = await db
|
||||
@@ -86,40 +159,43 @@ export class IamRepository {
|
||||
.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: {
|
||||
id: string;
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
jti?: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<void> {
|
||||
const db = getDb();
|
||||
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> {
|
||||
const db = getDb();
|
||||
await db
|
||||
@@ -127,4 +203,116 @@ export class IamRepository {
|
||||
.set({ revokedAt: new Date() })
|
||||
.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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,40 +4,79 @@ import {
|
||||
char,
|
||||
timestamp,
|
||||
text,
|
||||
int,
|
||||
mysqlEnum,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// DataScope 6 级(president §3.2:SUBJECT 替代 DISTRICT)
|
||||
export const DATA_SCOPES = [
|
||||
"self",
|
||||
"subject",
|
||||
"class",
|
||||
"grade",
|
||||
"school",
|
||||
"all",
|
||||
] as const;
|
||||
export type DataScope = (typeof DATA_SCOPES)[number];
|
||||
|
||||
// 三层角色模型(P2.2:system / 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", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("active"),
|
||||
dataScope: mysqlEnum("data_scope", [
|
||||
"self",
|
||||
"class",
|
||||
"grade",
|
||||
"school",
|
||||
"district",
|
||||
"all",
|
||||
])
|
||||
.notNull()
|
||||
.default("self"),
|
||||
dataScope: mysqlEnum("data_scope", DATA_SCOPES).notNull().default("self"),
|
||||
// 密码策略:记录上次修改时间,用于过期校验
|
||||
passwordChangedAt: timestamp("password_changed_at").notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export const roles = mysqlTable("iam_roles", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 50 }).notNull().unique(),
|
||||
description: varchar("description", { length: 255 }),
|
||||
});
|
||||
export const roles = mysqlTable(
|
||||
"iam_roles",
|
||||
{
|
||||
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", {
|
||||
userId: char("user_id", { length: 36 }).notNull(),
|
||||
roleId: char("role_id", { length: 36 }).notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
export const userRoles = mysqlTable(
|
||||
"iam_user_roles",
|
||||
{
|
||||
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", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
@@ -46,36 +85,128 @@ export const permissions = mysqlTable("iam_permissions", {
|
||||
action: varchar("action", { length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
export const rolePermissions = mysqlTable("iam_role_permissions", {
|
||||
roleId: char("role_id", { length: 36 }).notNull(),
|
||||
permissionId: char("permission_id", { length: 36 }).notNull(),
|
||||
});
|
||||
export const rolePermissions = mysqlTable(
|
||||
"iam_role_permissions",
|
||||
{
|
||||
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", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
userId: char("user_id", { length: 36 }).notNull(),
|
||||
tokenHash: varchar("token_hash", { length: 255 }).notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
revokedAt: timestamp("revoked_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
export const refreshTokens = mysqlTable(
|
||||
"iam_refresh_tokens",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
userId: char("user_id", { length: 36 }).notNull(),
|
||||
tokenHash: varchar("token_hash", { length: 255 }).notNull(),
|
||||
// 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 数据)
|
||||
// 每条记录代表一个角色可见的导航项
|
||||
export const roleViewports = mysqlTable("iam_role_viewports", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
roleId: char("role_id", { length: 36 }).notNull(),
|
||||
viewportKey: varchar("viewport_key", { length: 50 }).notNull(),
|
||||
label: varchar("label", { length: 100 }).notNull(),
|
||||
route: varchar("route", { length: 200 }).notNull(),
|
||||
icon: varchar("icon", { length: 50 }),
|
||||
sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"),
|
||||
requiredPermission: varchar("required_permission", { length: 100 }),
|
||||
// L3 组件级配置(JSON):控制组件内按钮/操作的显隐
|
||||
componentConfig: text("component_config"),
|
||||
});
|
||||
export const roleViewports = mysqlTable(
|
||||
"iam_role_viewports",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
roleId: char("role_id", { length: 36 }).notNull(),
|
||||
viewportKey: varchar("viewport_key", { length: 50 }).notNull(),
|
||||
label: varchar("label", { length: 100 }).notNull(),
|
||||
route: varchar("route", { length: 200 }).notNull(),
|
||||
icon: varchar("icon", { length: 50 }),
|
||||
sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"),
|
||||
requiredPermission: varchar("required_permission", { length: 100 }),
|
||||
// 视口层级: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 Role = typeof roles.$inferSelect;
|
||||
export type Permission = typeof permissions.$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;
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Inject } from "@nestjs/common";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { IamRepository } from "./iam.repository.js";
|
||||
import { JwksService } from "./jwks.service.js";
|
||||
import {
|
||||
ConflictError,
|
||||
UnauthorizedError,
|
||||
NotFoundError,
|
||||
} from "../shared/errors/application-error.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 { 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 {
|
||||
accessToken: string;
|
||||
@@ -25,6 +32,7 @@ export interface UserInfo {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ViewportItem {
|
||||
@@ -36,14 +44,39 @@ export interface ViewportItem {
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
// teacher 角色固定 ID(种子数据)
|
||||
const TEACHER_ROLE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
export interface ChildInfo {
|
||||
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 事件发布(I5:UserEvent / RoleEvent / AuditEvent)
|
||||
* - 审计日志(president §5.5)
|
||||
* - Redis 权限缓存 + token 黑名单(I3 / I7)
|
||||
*/
|
||||
@Injectable()
|
||||
export class IamService {
|
||||
constructor(
|
||||
@Inject(IamRepository) private readonly repository: IamRepository,
|
||||
private readonly jwksService: JwksService,
|
||||
private readonly outbox: OutboxService,
|
||||
private readonly tokenBlacklist: TokenBlacklistService,
|
||||
private readonly permissionCache: PermissionCacheService,
|
||||
) {}
|
||||
|
||||
// ============ 认证类 ============
|
||||
|
||||
async register(
|
||||
dto: RegisterDto,
|
||||
): Promise<{ user: UserInfo; tokens: TokenPair }> {
|
||||
@@ -52,7 +85,7 @@ export class IamService {
|
||||
throw new ConflictError("Email already registered");
|
||||
}
|
||||
|
||||
const userId = uuidv4();
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||
const user = await this.repository.createUser({
|
||||
id: userId,
|
||||
@@ -61,10 +94,36 @@ export class IamService {
|
||||
name: dto.name,
|
||||
});
|
||||
|
||||
// 默认分配 teacher 角色
|
||||
await this.repository.assignRole(userId, TEACHER_ROLE_ID);
|
||||
await this.repository.assignRole(userId, DEFAULT_ROLE_ID);
|
||||
|
||||
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);
|
||||
return { user: info, tokens };
|
||||
}
|
||||
@@ -85,14 +144,23 @@ export class IamService {
|
||||
}
|
||||
|
||||
const { tokens } = await this.issueTokens(user);
|
||||
|
||||
// 审计日志
|
||||
await this.writeAuditLog(user.id, "login", "user", user.id, null, null);
|
||||
|
||||
const info = await this.buildUserInfo(user);
|
||||
return { user: info, tokens };
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<TokenPair> {
|
||||
const keyPair = getJwtKeyPair();
|
||||
let payload: jwt.JwtPayload;
|
||||
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") {
|
||||
throw new UnauthorizedError("Invalid refresh token");
|
||||
}
|
||||
@@ -106,18 +174,59 @@ export class IamService {
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === "string" ? payload.sub : undefined;
|
||||
const jti = typeof payload.jti === "string" ? payload.jti : undefined;
|
||||
if (!sub) {
|
||||
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);
|
||||
if (!user) {
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
@@ -126,18 +235,50 @@ export class IamService {
|
||||
return this.buildUserInfo(user);
|
||||
}
|
||||
|
||||
// 有效权限聚合:多角色权限去重
|
||||
async getEffectivePermissions(userId: string): Promise<string[]> {
|
||||
const perms = await this.repository.getUserPermissions(userId);
|
||||
const unique = new Set(perms.map((p) => p.name));
|
||||
return [...unique];
|
||||
async batchGetUsers(userIds: string[]): Promise<UserInfo[]> {
|
||||
const users = await this.repository.batchFindUsers(userIds);
|
||||
return Promise.all(users.map((u) => this.buildUserInfo(u)));
|
||||
}
|
||||
|
||||
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 permissions = await this.getEffectivePermissions(userId);
|
||||
|
||||
// 过滤:如果视口需要权限且用户不具备,则不返回
|
||||
return viewports
|
||||
.filter((vp) => {
|
||||
if (!vp.requiredPermission) return true;
|
||||
@@ -154,18 +295,42 @@ export class IamService {
|
||||
.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();
|
||||
}
|
||||
|
||||
async getAllPermissions(): Promise<Permission[]> {
|
||||
async getAllPermissions() {
|
||||
return this.repository.getAllPermissions();
|
||||
}
|
||||
|
||||
// ============ 私有方法 ============
|
||||
|
||||
private async issueTokens(user: User): Promise<{ tokens: TokenPair }> {
|
||||
const keyPair = getJwtKeyPair();
|
||||
const roles = await this.repository.getUserRoles(user.id);
|
||||
const roleNames = roles.map((r) => r.name);
|
||||
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(
|
||||
{
|
||||
@@ -175,30 +340,43 @@ export class IamService {
|
||||
dataScope,
|
||||
type: "access",
|
||||
},
|
||||
env.JWT_SECRET,
|
||||
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "15m" },
|
||||
keyPair.privateKey,
|
||||
{
|
||||
algorithm: "RS256",
|
||||
issuer: env.JWT_ISSUER,
|
||||
audience: env.JWT_AUDIENCE,
|
||||
expiresIn: ttlToSeconds(accessTtl),
|
||||
keyid: keyPair.kid,
|
||||
},
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ sub: user.id, type: "refresh" },
|
||||
env.JWT_SECRET,
|
||||
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "7d" },
|
||||
{ sub: user.id, type: "refresh", jti },
|
||||
keyPair.privateKey,
|
||||
{
|
||||
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);
|
||||
await this.repository.createRefreshToken({
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
jti,
|
||||
expiresAt: new Date(Date.now() + refreshTtlSeconds * 1000),
|
||||
});
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 15 * 60,
|
||||
expiresIn: ttlToSeconds(accessTtl),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -213,6 +391,51 @@ export class IamService {
|
||||
roles: roles.map((r) => r.name),
|
||||
permissions: permissions.map((p) => p.name),
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
services/iam/src/iam/jwks.controller.ts
Normal file
21
services/iam/src/iam/jwks.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
69
services/iam/src/iam/jwks.service.ts
Normal file
69
services/iam/src/iam/jwks.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,29 @@
|
||||
import { Controller, Get, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
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 {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
// RBAC 管理端点:角色/权限/视口查询
|
||||
@Controller("iam")
|
||||
/**
|
||||
* RBAC 管理端点:角色/权限查询(admin-portal 使用)。
|
||||
*
|
||||
* 路径前缀:/v1/iam(I7 裁决)
|
||||
*/
|
||||
@Controller("v1/iam")
|
||||
export class RbacController {
|
||||
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")
|
||||
@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();
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
// 列出所有权限点(管理端用)
|
||||
@Get("permissions")
|
||||
@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();
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import { getJwtKeyPair } from "./config/jwt.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* IAM 服务启动入口。
|
||||
*
|
||||
* 双入口(president §2.16):
|
||||
* - HTTP server:env.PORT(3002),供 gateway 透传 + admin-portal 直连
|
||||
* - gRPC server:env.GRPC_PORT(50052),供 BFF 聚合调用(I1 裁决)
|
||||
*
|
||||
* 启动顺序:
|
||||
* 1. initTracer(OTel SDK)
|
||||
* 2. 创建 NestApplication
|
||||
* 3. 注册 GlobalErrorFilter
|
||||
* 4. 启动 gRPC microservice(hybrid app)
|
||||
* 5. 启动 HTTP server
|
||||
* 6. 预加载 JWT 密钥对(确保文件可读)
|
||||
*/
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
// 预加载 JWT 密钥对(启动时即校验文件可读,避免运行时才发现配置错误)
|
||||
getJwtKeyPair();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
@@ -18,15 +38,30 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
// gRPC microservice(端口 50052,I1 裁决)
|
||||
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) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
// 启动 hybrid app(HTTP + gRPC)
|
||||
await app.startAllMicroservices();
|
||||
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 () => {
|
||||
await app.close();
|
||||
|
||||
@@ -5,20 +5,27 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
/**
|
||||
* 已认证请求:由 AuthMiddleware 从 Gateway 注入的 x-user-* 头部解析。
|
||||
* 公开端点(login/register/refresh/jwks/health)不走此中间件。
|
||||
*/
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
userRoles?: string[];
|
||||
userDataScope?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
||||
// 从 Gateway 注入的头部读取用户信息
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
const rolesHeaderRaw = req.headers["x-user-roles"];
|
||||
const rolesHeader =
|
||||
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
|
||||
const dataScopeHeaderRaw = req.headers["x-user-data-scope"];
|
||||
const dataScopeHeader =
|
||||
typeof dataScopeHeaderRaw === "string" ? dataScopeHeaderRaw : undefined;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Missing x-user-id header");
|
||||
@@ -26,6 +33,7 @@ export class AuthMiddleware implements NestMiddleware {
|
||||
|
||||
req.userId = userId;
|
||||
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
|
||||
req.userDataScope = dataScopeHeader ?? "self";
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,43 +6,50 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
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";
|
||||
|
||||
export type Permission =
|
||||
| "IAM_USER_CREATE"
|
||||
| "IAM_USER_READ"
|
||||
| "IAM_USER_UPDATE"
|
||||
| "IAM_USER_DELETE"
|
||||
| "IAM_ROLE_MANAGE";
|
||||
|
||||
/**
|
||||
* 权限点常量(对齐 iam-init.sql 种子数据)。
|
||||
*
|
||||
* 权限名格式:`<resource>:<action>`(如 `iam:user:read`)。
|
||||
* DB 中存储的权限名与 controller 装饰器声明的权限名一一对应。
|
||||
*/
|
||||
export const Permissions = {
|
||||
IAM_USER_CREATE: "IAM_USER_CREATE" as const,
|
||||
IAM_USER_READ: "IAM_USER_READ" as const,
|
||||
IAM_USER_UPDATE: "IAM_USER_UPDATE" as const,
|
||||
IAM_USER_DELETE: "IAM_USER_DELETE" as const,
|
||||
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE" as const,
|
||||
};
|
||||
IAM_USER_READ: "iam:user:read",
|
||||
IAM_USER_MANAGE: "iam:user:manage",
|
||||
IAM_ROLE_MANAGE: "iam:role:manage",
|
||||
IAM_AUDIT_READ: "iam:audit:read",
|
||||
IAM_VIEWPORT_READ: "iam:viewport:read",
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
|
||||
export const PERMISSIONS_KEY = "permissions";
|
||||
export const RequirePermission = (...permissions: Permission[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.IAM_USER_CREATE,
|
||||
Permissions.IAM_USER_READ,
|
||||
Permissions.IAM_USER_UPDATE,
|
||||
Permissions.IAM_USER_DELETE,
|
||||
Permissions.IAM_ROLE_MANAGE,
|
||||
],
|
||||
teacher: [Permissions.IAM_USER_READ],
|
||||
};
|
||||
|
||||
/**
|
||||
* 权限守卫(I3 裁决:DB 驱动 + Redis 缓存)。
|
||||
*
|
||||
* 校验流程:
|
||||
* 1. 从 req.userId 获取当前用户(由 AuthMiddleware 注入)
|
||||
* 2. 先查 Redis 缓存(TTL 5min)
|
||||
* 3. 未命中则查 DB(role_permissions JOIN),并回填缓存
|
||||
* 4. 检查用户权限列表是否包含所需权限
|
||||
*
|
||||
* admin 角色拥有全部权限(data_scope=all 时跳过 DB 查询直接放行)。
|
||||
*/
|
||||
@Injectable()
|
||||
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") {
|
||||
return true;
|
||||
}
|
||||
@@ -57,15 +64,33 @@ export class PermissionGuard implements CanActivate {
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const roles = request.userRoles ?? [];
|
||||
|
||||
for (const role of roles) {
|
||||
const perms = ROLE_PERMISSIONS[role];
|
||||
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
const userId = request.userId;
|
||||
if (!userId) {
|
||||
throw new PermissionDeniedError("missing user identity");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
51
services/iam/src/shared/cache/permission-cache.service.ts
vendored
Normal file
51
services/iam/src/shared/cache/permission-cache.service.ts
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
38
services/iam/src/shared/cache/token-blacklist.service.ts
vendored
Normal file
38
services/iam/src/shared/cache/token-blacklist.service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import { getRedis } from "../../config/redis.js";
|
||||
import { getJwtKeyPair } from "../../config/jwt.js";
|
||||
|
||||
const SERVICE_NAME = "iam";
|
||||
|
||||
interface DependencyCheck {
|
||||
name: string;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖
|
||||
* - GET /readyz:readiness,检查 5 依赖(DB/Redis/Kafka/gRPC/JWKS),失败返回 503
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@@ -29,26 +34,104 @@ export class HealthController {
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
dependencies: DependencyCheck[];
|
||||
}> {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const checks: DependencyCheck[] = [];
|
||||
|
||||
// 1. DB
|
||||
checks.push(await this.checkDb());
|
||||
|
||||
// 2. Redis
|
||||
checks.push(await this.checkRedis());
|
||||
|
||||
// 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(
|
||||
{
|
||||
status: "error",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error:
|
||||
error instanceof Error ? error.message : "database unreachable",
|
||||
dependencies: checks,
|
||||
},
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,21 @@ import {
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { closeDb } from "../../config/database.js";
|
||||
import { closeRedis } from "../../config/redis.js";
|
||||
import { disconnectKafkaProducer } from "../../config/kafka.js";
|
||||
|
||||
const SERVICE_NAME = "iam";
|
||||
|
||||
/**
|
||||
* 优雅停机服务。
|
||||
*
|
||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||
* 关闭顺序(president §2.16 + I5 Outbox 依赖 Kafka):
|
||||
* 1. HTTP/gRPC server 已由 NestJS app.close() 停止
|
||||
* 2. Kafka producer 断开(停止投递 Outbox 事件)
|
||||
* 3. Redis 断开(停止缓存读写)
|
||||
* 4. DB 连接池关闭(最后关闭,确保 Outbox publisher 已完成残余投递)
|
||||
*
|
||||
* IAM 服务仅使用 Drizzle ORM(MySQL),无 Kafka / Redis 依赖。
|
||||
* 关闭时仅需关闭数据库连接池。
|
||||
* K8s terminationGracePeriodSeconds=60 给予足够时间清理。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
@@ -31,6 +34,27 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
`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 {
|
||||
await closeDb();
|
||||
this.logger.log("database connection closed");
|
||||
|
||||
Reference in New Issue
Block a user