diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index e3b072e..649cba8 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -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`,默认导入在 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) diff --git a/packages/shared-proto/proto/events.proto b/packages/shared-proto/proto/events.proto index 005f3c3..9cbfb11 100644 --- a/packages/shared-proto/proto/events.proto +++ b/packages/shared-proto/proto/events.proto @@ -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.... // -// 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 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 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 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 metadata = 14; +} diff --git a/packages/shared-proto/proto/iam.proto b/packages/shared-proto/proto/iam.proto index b0e3277..2dd793b 100644 --- a/packages/shared-proto/proto/iam.proto +++ b/packages/shared-proto/proto/iam.proto @@ -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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4a295..f9381b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/iam-init.sql b/scripts/iam-init.sql index eb61ba4..1292482 100644 --- a/scripts/iam-init.sql +++ b/scripts/iam-init.sql @@ -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); diff --git a/services/iam/README.md b/services/iam/README.md index 60f8dcd..22c3f69 100644 --- a/services/iam/README.md +++ b/services/iam/README.md @@ -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 +``` diff --git a/services/iam/docs/02-architecture-design.md b/services/iam/docs/02-architecture-design.md index 071778a..d1a2539 100644 --- a/services/iam/docs/02-architecture-design.md +++ b/services/iam/docs/02-architecture-design.md @@ -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 请求
带 x-user-id / x-user-roles 头] + REST[HTTP 请求
带 x-user-id / x-user-roles / x-user-data-scope 头] + GRPC[gRPC 调用
next_edu_cloud.iam.v1] end - subgraph NestJS["iam 服务(NestJS)"] + subgraph NestJS["iam 服务(NestJS Hybrid App)"] direction TB - MW[AuthMiddleware
❌ 当前未注册,P2 仍走 header 直读] - Guard[PermissionGuard
APP_GUARD 全局守卫] + MW[AuthMiddleware
已注册:me/logout/viewports/
permissions/roles/audit/children] + Guard[PermissionGuard
APP_GUARD 全局守卫
DB 驱动 + Redis 缓存] Filter[GlobalErrorFilter
全局异常过滤器] - subgraph Controllers["Controller 层"] - IamCtl[IamController
/iam/register, login, refresh, me] - RbacCtl[RbacController
/iam/viewports, permissions, roles] - UserCtl[UserController
P2 新增: 用户 CRUD] - RoleCtl[RoleController
P2 新增: 角色 CRUD] - ViewportCtl[ViewportController
P2 新增: 视口 CRUD] - JwksCtl[JwksController
P2 新增: RS256 公钥暴露] + subgraph Controllers["Controller 层(双入口)"] + IamCtl[IamController
REST /v1/iam/*] + RbacCtl[RbacController
REST /v1/iam/roles, /permissions] + AuditCtl[AuditController
REST /v1/iam/audit] + JwksCtl[JwksController
GET /v1/iam/.well-known/jwks.json] + GrpcCtl[IamGrpcController
12 RPC @ GrpcMethod] end subgraph Services["Application Service 层"] - IamSvc[IamService
认证编排] - RbacSvc[RbacService
RBAC 编排] - UserSvc[UserService
用户领域编排] - CacheSvc[PermissionCacheService
Redis 权限缓存] - end - - subgraph Domain["Domain 层(P2 轻量)"] - UserEntity[UserEntity
聚合根] - RoleEntity[RoleEntity
聚合根] + IamSvc[IamService
认证 + RBAC + 审计编排
REST 与 gRPC 共用] + CacheSvc[PermissionCacheService
Redis 权限缓存 TTL 5min] + BlacklistSvc[TokenBlacklistService
Redis jti 黑名单] + JwksSvc[JwksService
PEM→JWK 转换] end subgraph Repo["Repository 层"] - IamRepo[IamRepository
Drizzle 查询] - RbacRepo[RbacRepository
Drizzle 查询] + IamRepo[IamRepository
Drizzle 查询 10 表] end - subgraph Outbox["Outbox 模块"] - OutboxTbl[(iam_outbox 表)] - Relay[OutboxRelayWorker
后台轮询 + Kafka 投递] + subgraph Outbox["Outbox 模块(shared-ts)"] + OutboxPub[OutboxPublisher
事务内写入] + OutboxRelay[OutboxRelayWorker
后台轮询 + Kafka 投递] end subgraph Infra["基础设施"] Db[(MySQL
iam_db)] Redis[(Redis
权限缓存 + token 黑名单)] - Kafka[(Kafka
edu.identity.user.* topic)] + Kafka[(Kafka
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 metadata = 11; // trace_id 等 + string action = 10; + map 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 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 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 diff --git a/services/iam/package.json b/services/iam/package.json index d1e6cee..597473d 100644 --- a/services/iam/package.json +++ b/services/iam/package.json @@ -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", diff --git a/services/iam/src/app.module.ts b/services/iam/src/app.module.ts index 34792c9..b506e1c 100644 --- a/services/iam/src/app.module.ts +++ b/services/iam/src/app.module.ts @@ -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 { + // 连接 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", + ); + } +} diff --git a/services/iam/src/config/database.ts b/services/iam/src/config/database.ts index 6cf3d28..244c89b 100644 --- a/services/iam/src/config/database.ts +++ b/services/iam/src/config/database.ts @@ -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 { if (pool) { await pool.end(); pool = null; + dbInstance = null; } } diff --git a/services/iam/src/config/env.ts b/services/iam/src/config/env.ts index 9f9d2d9..8e48295 100644 --- a/services/iam/src/config/env.ts +++ b/services/iam/src/config/env.ts @@ -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; @@ -17,8 +41,11 @@ export type Env = z.infer; 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; } diff --git a/services/iam/src/config/jwt.ts b/services/iam/src/config/jwt.ts new file mode 100644 index 0000000..087a482 --- /dev/null +++ b/services/iam/src/config/jwt.ts @@ -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]; +} diff --git a/services/iam/src/config/kafka.ts b/services/iam/src/config/kafka.ts new file mode 100644 index 0000000..5090f56 --- /dev/null +++ b/services/iam/src/config/kafka.ts @@ -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 { + const p = getKafkaProducer(); + await p.connect(); +} + +export async function disconnectKafkaProducer(): Promise { + if (producer) { + await producer.disconnect(); + producer = null; + } +} + +/** + * IAM Kafka topic 路由(coord-final-decisions I5 + iam_contract §1.4)。 + * + * 事件命名规则:`.` + * - 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; diff --git a/services/iam/src/config/redis.ts b/services/iam/src/config/redis.ts new file mode 100644 index 0000000..107f415 --- /dev/null +++ b/services/iam/src/config/redis.ts @@ -0,0 +1,24 @@ +import { Redis } from "ioredis"; +import { env } from "./env.js"; + +type RedisClient = InstanceType; + +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 { + if (client) { + await client.quit(); + client = null; + } +} diff --git a/services/iam/src/iam/audit.controller.ts b/services/iam/src/iam/audit.controller.ts new file mode 100644 index 0000000..1eb4584 --- /dev/null +++ b/services/iam/src/iam/audit.controller.ts @@ -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 }; + } +} diff --git a/services/iam/src/iam/iam.controller.ts b/services/iam/src/iam/iam.controller.ts index 1b34094..9201708 100644 --- a/services/iam/src/iam/iam.controller.ts +++ b/services/iam/src/iam/iam.controller.ts @@ -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 }; + } } diff --git a/services/iam/src/iam/iam.dto.ts b/services/iam/src/iam/iam.dto.ts index d9d4518..5e6809c 100644 --- a/services/iam/src/iam/iam.dto.ts +++ b/services/iam/src/iam/iam.dto.ts @@ -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; export type LoginDto = z.infer; +export type LogoutDto = z.infer; diff --git a/services/iam/src/iam/iam.grpc.controller.ts b/services/iam/src/iam/iam.grpc.controller.ts new file mode 100644 index 0000000..1318116 --- /dev/null +++ b/services/iam/src/iam/iam.grpc.controller.ts @@ -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 { + 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 { + 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 { + 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 { + const user = await this.service.getUserInfo(data.userId); + return this.toUserInfoProto(user); + } + + @GrpcMethod("IamService", "BatchGetUsers") + async batchGetUsers(data: { userIds: string[] }): Promise { + 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 { + 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 { + 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 { + 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 { + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + }; + } +} diff --git a/services/iam/src/iam/iam.module.ts b/services/iam/src/iam/iam.module.ts index 843c5a3..21852bd 100644 --- a/services/iam/src/iam/iam.module.ts +++ b/services/iam/src/iam/iam.module.ts @@ -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 {} diff --git a/services/iam/src/iam/iam.repository.ts b/services/iam/src/iam/iam.repository.ts index 0f2215b..d390eff 100644 --- a/services/iam/src/iam/iam.repository.ts +++ b/services/iam/src/iam/iam.repository.ts @@ -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 { const db = getDb(); await db.insert(users).values(data); @@ -43,6 +63,27 @@ export class IamRepository { return result; } + async batchFindUsers(ids: string[]): Promise { + 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 { + const db = getDb(); + await db.update(users).set({ status }).where(eq(users.id, userId)); + } + + async updatePassword(userId: string, passwordHash: string): Promise { + const db = getDb(); + await db + .update(users) + .set({ passwordHash, passwordChangedAt: new Date() }) + .where(eq(users.id, userId)); + } + + // ============ 角色 ============ + async getUserRoles(userId: string): Promise { const db = getDb(); const result = await db @@ -53,6 +94,31 @@ export class IamRepository { return result.map((r) => r.iam_roles); } + async getAllRoles(): Promise { + const db = getDb(); + return db.select().from(roles); + } + + async findRoleByName(name: string): Promise { + const db = getDb(); + const [result] = await db.select().from(roles).where(eq(roles.name, name)); + return result; + } + + async assignRole(userId: string, roleId: string): Promise { + const db = getDb(); + await db.insert(userRoles).values({ userId, roleId }); + } + + async revokeRole(userId: string, roleId: string): Promise { + const db = getDb(); + await db + .delete(userRoles) + .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))); + } + + // ============ 权限 ============ + async getUserPermissions(userId: string): Promise { const db = getDb(); const userRoleRows = await db @@ -72,6 +138,13 @@ export class IamRepository { return result.map((r) => r.iam_permissions); } + async getAllPermissions(): Promise { + const db = getDb(); + return db.select().from(permissions); + } + + // ============ 视口 ============ + async getUserViewports(userId: string): Promise { const db = getDb(); const userRoleRows = await db @@ -86,40 +159,43 @@ export class IamRepository { .where(inArray(roleViewports.roleId, roleIds)); } - async getUserDataScope(userId: string): Promise { - 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 { - const db = getDb(); - return db.select().from(roles); - } - - async getAllPermissions(): Promise { - const db = getDb(); - return db.select().from(permissions); - } - - async assignRole(userId: string, roleId: string): Promise { - const db = getDb(); - await db.insert(userRoles).values({ userId, roleId }); - } + // ============ 刷新令牌 ============ async createRefreshToken(data: { id: string; userId: string; tokenHash: string; + jti?: string; expiresAt: Date; }): Promise { 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 { 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 { + 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 { + 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 { + const db = getDb(); + await db.insert(userAuditLog).values(data); + } + + async queryAuditLog(options: { + actorUserId?: string; + resourceType?: string; + resourceId?: string; + limit?: number; + offset?: number; + }): Promise { + 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 { + 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 { + const db = getDb(); + const id = crypto.randomUUID(); + await db.insert(passwordHistory).values({ id, userId, passwordHash }); + } } diff --git a/services/iam/src/iam/iam.schema.ts b/services/iam/src/iam/iam.schema.ts index c2603f3..528ab0f 100644 --- a/services/iam/src/iam/iam.schema.ts +++ b/services/iam/src/iam/iam.schema.ts @@ -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; diff --git a/services/iam/src/iam/iam.service.ts b/services/iam/src/iam/iam.service.ts index b3d6d90..bfe71e0 100644 --- a/services/iam/src/iam/iam.service.ts +++ b/services/iam/src/iam/iam.service.ts @@ -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 { + 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 { + 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 { 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 { - const perms = await this.repository.getUserPermissions(userId); - const unique = new Set(perms.map((p) => p.name)); - return [...unique]; + async batchGetUsers(userIds: string[]): Promise { + const users = await this.repository.batchFindUsers(userIds); + return Promise.all(users.map((u) => this.buildUserInfo(u))); } - async getUserViewports(userId: string): Promise { + // ============ 权限与视口类 ============ + + async getEffectivePermissions(userId: string): Promise { + // 先查缓存 + 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 { + const user = await this.repository.findUserById(userId); + if (!user) { + throw new NotFoundError("User", userId); + } + return user.dataScope; + } + + async getViewports(userId: string): Promise { 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 { + // ============ 密钥与关系类 ============ + + getPublicKey(): { kid: string; alg: string; publicKeyPem: string } { + return this.jwksService.getPublicKeyPem(); + } + + async getChildrenByParent(parentId: string): Promise { + 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 { + 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 { + 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 }, + ); + } } diff --git a/services/iam/src/iam/jwks.controller.ts b/services/iam/src/iam/jwks.controller.ts new file mode 100644 index 0000000..98c98c2 --- /dev/null +++ b/services/iam/src/iam/jwks.controller.ts @@ -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(); + } +} diff --git a/services/iam/src/iam/jwks.service.ts b/services/iam/src/iam/jwks.service.ts new file mode 100644 index 0000000..0f70b28 --- /dev/null +++ b/services/iam/src/iam/jwks.service.ts @@ -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, + }; + } +} diff --git a/services/iam/src/iam/rbac.controller.ts b/services/iam/src/iam/rbac.controller.ts index 7e5c92f..aeba7c0 100644 --- a/services/iam/src/iam/rbac.controller.ts +++ b/services/iam/src/iam/rbac.controller.ts @@ -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 }; } diff --git a/services/iam/src/main.ts b/services/iam/src/main.ts index 13f0f53..65a498f 100644 --- a/services/iam/src/main.ts +++ b/services/iam/src/main.ts @@ -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 { initTracer(); + // 预加载 JWT 密钥对(启动时即校验文件可读,避免运行时才发现配置错误) + getJwtKeyPair(); + const app = await NestFactory.create(AppModule, { logger: ["log", "error", "warn"], }); @@ -18,15 +38,30 @@ async function bootstrap(): Promise { app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); - // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 - // 返回 register.metrics()(Promise,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。 + // gRPC microservice(端口 50052,I1 裁决) + app.connectMicroservice({ + 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(); diff --git a/services/iam/src/middleware/auth.middleware.ts b/services/iam/src/middleware/auth.middleware.ts index bc7bab9..bc066d0 100644 --- a/services/iam/src/middleware/auth.middleware.ts +++ b/services/iam/src/middleware/auth.middleware.ts @@ -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(); } } diff --git a/services/iam/src/middleware/permission.guard.ts b/services/iam/src/middleware/permission.guard.ts index c7a6ffd..910b1e6 100644 --- a/services/iam/src/middleware/permission.guard.ts +++ b/services/iam/src/middleware/permission.guard.ts @@ -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 种子数据)。 + * + * 权限名格式:`:`(如 `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 = { - 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 { if (process.env.DEV_MODE === "true") { return true; } @@ -57,15 +64,33 @@ export class PermissionGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); - 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 { + 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; } } diff --git a/services/iam/src/shared/cache/permission-cache.service.ts b/services/iam/src/shared/cache/permission-cache.service.ts new file mode 100644 index 0000000..6df208b --- /dev/null +++ b/services/iam/src/shared/cache/permission-cache.service.ts @@ -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 { + 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 { + const redis = getRedis(); + await redis.set( + PermissionCacheService.buildKey(userId), + JSON.stringify(permissions), + "EX", + PERMISSION_CACHE_TTL_SECONDS, + ); + } + + async invalidate(userId: string): Promise { + const redis = getRedis(); + await redis.del(PermissionCacheService.buildKey(userId)); + } +} diff --git a/services/iam/src/shared/cache/token-blacklist.service.ts b/services/iam/src/shared/cache/token-blacklist.service.ts new file mode 100644 index 0000000..54ed5f7 --- /dev/null +++ b/services/iam/src/shared/cache/token-blacklist.service.ts @@ -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 { + 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 { + if (ttlSeconds <= 0) return; // 已过期,无需加入 + const redis = getRedis(); + await redis.set(TokenBlacklistService.buildKey(jti), "1", "EX", ttlSeconds); + } +} diff --git a/services/iam/src/shared/health/health.controller.ts b/services/iam/src/shared/health/health.controller.ts index 59308c5..f720394 100644 --- a/services/iam/src/shared/health/health.controller.ts +++ b/services/iam/src/shared/health/health.controller.ts @@ -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 { + 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 { + 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 { + 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), + }; + } } } diff --git a/services/iam/src/shared/lifecycle/lifecycle.service.ts b/services/iam/src/shared/lifecycle/lifecycle.service.ts index 26195d3..9ca3856 100644 --- a/services/iam/src/shared/lifecycle/lifecycle.service.ts +++ b/services/iam/src/shared/lifecycle/lifecycle.service.ts @@ -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");