feat(iam): graphql subgraph with dataloader and router auth guard

- GraphQLModule: Apollo Federation 2 subgraph at /graphql

- UserResolver/RoleResolver: @key with @ResolveReference using DataLoader (ADR-035)

- DataScopeResolver: ScopeToken for visible class/student IDs (ADR-041)

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- DataLoaderService: REQUEST-scoped userLoader + roleLoader

- IamRepository.batchFindRoles: batch query for DataLoader

- IamService.getVisibleClassIds/getVisibleStudentIds: dataScope calculation

- app.module.ts: register PermissionGuard + RouterAuthGuard as APP_GUARD
This commit is contained in:
SpecialX
2026-07-14 23:48:16 +08:00
parent 5fcb831a18
commit 6bed673d9f
12 changed files with 694 additions and 6 deletions

View File

@@ -12,11 +12,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@apollo/subgraph": "^2.2.3",
"@edu/shared-ts": "workspace:*",
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/apollo": "^12.2.0",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/graphql": "^12.2.0",
"@nestjs/microservices": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@opentelemetry/api": "^1.9.0",
@@ -24,7 +27,9 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"bcrypt": "^5.1.0",
"dataloader": "^2.2.2",
"drizzle-orm": "^0.31.0",
"graphql": "^16.9.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.0",
"kafkajs": "^2.2.4",

View File

@@ -11,6 +11,8 @@ 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 { GraphqlModule } from "./graphql/graphql.module.js";
import { RouterAuthGuard } from "./graphql/router-auth.guard.js";
import { OutboxModule } from "@edu/shared-ts/outbox";
import { getDbInstance } from "./config/database.js";
import {
@@ -20,19 +22,22 @@ import {
} from "./config/kafka.js";
/**
* IAM 根模块。
* IAM 根模块v2.1
*
* 装配:
* - IamModule业务
* - HealthModule健康检查
* - OutboxModule事务性事件发布I5 裁决
* - GraphqlModuleApollo Federation 子图v2.1 新增
* - OutboxModule事务性事件发布I5 裁决v2.1 后投递由 Debezium 完成)
* - AuthMiddleware从 Gateway 注入的 x-user-* 头部解析用户身份)
* - PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存I3 裁决)
* - RouterAuthGuardAPP_GUARD仅 /graphql 端点生效ADR-036
*/
@Module({
imports: [
IamModule,
HealthModule,
GraphqlModule,
OutboxModule.forRoot({
config: {
tableName: "iam_outbox",
@@ -48,6 +53,7 @@ import {
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
{ provide: APP_GUARD, useClass: RouterAuthGuard },
LifecycleService,
],
})
@@ -56,6 +62,8 @@ export class AppModule implements NestModule, OnModuleInit {
async onModuleInit(): Promise<void> {
// 连接 Kafka producerOutbox 投递前置依赖)
// v2.1 注OutboxPublisher 轮询线程将被废弃M8由 Debezium 接管投递
// 此处保留 Kafka producer 连接用于其他场景(如直接发事件)
try {
await connectKafkaProducer();
this.logger.log("Kafka producer connected");

View File

@@ -0,0 +1,97 @@
/**
* iam DataLoader 服务v2.1 M1 / ADR-035
*
* 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。
*
* 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
* 通过 REQUEST scope 注入,确保不同请求不共享缓存。
*/
import { Injectable, Scope } from "@nestjs/common";
import DataLoader from "dataloader";
import { IamRepository } from "../iam/iam.repository.js";
/**
* User EntityGraphQL 输出类型)
* 对应 iam.users 表
*/
export interface UserEntity {
userId: string;
email: string;
name: string;
status: string;
dataScope: string;
}
/**
* Role EntityGraphQL 输出类型)
* 对应 iam.roles 表
*/
export interface RoleEntity {
roleId: string;
name: string;
description: string | null;
roleType: string;
level: number;
}
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderService {
private userLoaderInstance: DataLoader<string, UserEntity | null> | null =
null;
private roleLoaderInstance: DataLoader<string, RoleEntity | null> | null =
null;
constructor(private readonly iamRepository: IamRepository) {}
/** User @key 解析器 DataLoader */
get userLoader(): DataLoader<string, UserEntity | null> {
if (!this.userLoaderInstance) {
this.userLoaderInstance = new DataLoader<string, UserEntity | null>(
async (userIds) => {
// 复用已有的 batchFindUsers 方法
const users = await this.iamRepository.batchFindUsers([...userIds]);
const map = new Map(
users.map((u) => [
u.id,
{
userId: u.id,
email: u.email,
name: u.name,
status: u.status,
dataScope: u.dataScope,
} satisfies UserEntity,
]),
);
return userIds.map((id) => map.get(id) ?? null);
},
);
}
return this.userLoaderInstance;
}
/** Role @key 解析器 DataLoader */
get roleLoader(): DataLoader<string, RoleEntity | null> {
if (!this.roleLoaderInstance) {
this.roleLoaderInstance = new DataLoader<string, RoleEntity | null>(
async (roleIds) => {
// IamRepository 已有 batchFindRoles 方法(见下方调用)
const roles = await this.iamRepository.batchFindRoles([...roleIds]);
const map = new Map(
roles.map((r) => [
r.id,
{
roleId: r.id,
name: r.name,
description: r.description,
roleType: r.roleType,
level: r.level,
} satisfies RoleEntity,
]),
);
return roleIds.map((id) => map.get(id) ?? null);
},
);
}
return this.roleLoaderInstance;
}
}

View File

@@ -0,0 +1,187 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件iam.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
extend type Query
type UserInfo @key(fields: "id") {
id: String
email: String
name: String
roles: String
permissions: String
data_scope: String
status: String
}
input RegisterRequestInput {
email: String
password: String
name: String
}
input LoginRequestInput {
email: String
password: String
}
input RefreshTokenRequestInput {
refresh_token: String
}
input LogoutRequestInput {
refresh_token: String
user_id: String
}
input LogoutResponseInput {
success: Boolean
}
input AuthResponseInput {
user: UserInfo
tokens: TokenPair
}
type TokenPair {
access_token: String
refresh_token: String
expires_in: Int
}
input GetUserInfoRequestInput {
user_id: String
}
input BatchGetUsersRequestInput {
user_ids: String
}
input BatchGetUsersResponseInput {
users: UserInfo
}
input GetEffectivePermissionsRequestInput {
user_id: String
}
input EffectivePermissionsResponseInput {
permissions: String
}
input GetEffectiveAccessRequestInput {
user_id: String
permission: String
}
input EffectiveAccessResponseInput {
allowed: Boolean
data_scope: String
}
input GetEffectiveDataScopeRequestInput {
user_id: String
}
input DataScopeResponseInput {
data_scope: String
}
input GetViewportsRequestInput {
user_id: String
}
input ViewportsResponseInput {
viewports: ViewportItem
}
type ViewportItem {
key: String
label: String
route: String
icon: String
sort_order: String
required_permission: String
}
input GetPublicKeyRequestInput {
}
input PublicKeyResponseInput {
kid: String
alg: String
public_key_pem: String
}
input GetChildrenByParentRequestInput {
parent_id: String
}
input ChildrenResponseInput {
children: ChildInfo
}
type ChildInfo {
student_id: String
name: String
relation: String
}
type EffectiveDataScope {
user_id: String
level: String
scope_ids: String
school_id: String
}
input CreateUserRequestInput {
email: String
password: String
name: String
role_id: String
data_scope: String
}
input UpdateUserRequestInput {
user_id: String
name: String
email: String
status: String
data_scope: String
}
input DeleteUserRequestInput {
user_id: String
}
input DeleteUserResponseInput {
success: Boolean
}
extend type Query {
userInfo: UserInfo
}
extend type Query {
effectiveDataScope: EffectiveDataScope
}
extend type Query {
effectivePermissions: EffectivePermissionsResponse
}
extend type Query {
effectiveAccess: EffectiveAccessResponse
}
extend type Query {
viewports: ViewportsResponse
}
extend type Query {
publicKey: PublicKeyResponse
}
extend type Query {
childrenByParent: ChildrenResponse
}

View File

@@ -0,0 +1,59 @@
/**
* iam GraphQL 子图模块v2.1 M1
*
* Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。
*
* 强制约束:
* - @key 解析器必须使用 DataLoaderADR-035
* - RouterAuthGuard 校验 Router-Authorization HeaderADR-036
* - 外部 GraphQL + 内部 gRPC 边界ADR-037
* - DataScope 通过 ScopeToken 优化ADR-041
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { UserResolver } from "./resolvers/user.resolver.js";
import { RoleResolver } from "./resolvers/role.resolver.js";
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
import { DataLoaderService } from "./dataloader.service.js";
import { getRedis } from "../config/redis.js";
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),
federation: 2,
},
// /graphql 端点Apollo Router 访问入口)
path: "/graphql",
// 禁用 playground生产环境通过 Router 访问)
playground: process.env.NODE_ENV === "development",
introspection: process.env.NODE_ENV === "development",
// Context 从 HTTP headers 构造
context: (ctx: {
req: { headers: Record<string, string | undefined> };
}) => ({
req: ctx.req,
graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers),
}),
}),
],
providers: [
UserResolver,
RoleResolver,
DataScopeResolver,
DataLoaderService,
{
// 提供 Redis 实例供 DataScopeResolver 的 ScopeTokenService 使用
provide: "REDIS_CLIENT",
useFactory: () => getRedis(),
},
],
exports: [DataLoaderService],
})
export class GraphqlModule {}

View File

@@ -0,0 +1,107 @@
/**
* iam DataScope Resolverv2.1 M1 / ADR-024 / ADR-041
*
* 核心:通过 @requires 指令在子图间传递可见范围ScopeToken 优化)。
*
* 流程:
* 1. Apollo Router 调用 iam 子图,传入 userId
* 2. iam 计算 visibleClassIds基于 RBAC
* 3. 将 visibleClassIds 存入 Redis Set生成 ScopeToken
* 4. 返回 ScopeToken 给 Router
* 5. Router 通过 @requires 将 ScopeToken 传给 core-edu 子图
* 6. core-edu 从 Redis SMEMBERS 获取实际 ID 列表
*
* 优势:不传全量 ID 数组,降低 HTTP payload 开销ADR-041
*/
import {
Resolver,
Query,
Args,
ID,
ObjectType,
Field,
Directive,
Context,
} from "@nestjs/graphql";
import { Inject } from "@nestjs/common";
import { IamService } from "../../iam/iam.service.js";
import { ScopeTokenService, GraphqlContext } from "@edu/shared-ts/federation";
import type { Redis } from "ioredis";
/**
* UserDataScope 类型
* 包含 userId 和 classScopeToken用于 @requires 传递)
*/
@ObjectType()
@Directive(`@key(fields: "userId")`)
export class UserDataScope {
@Field(() => ID)
userId!: string;
/**
* 班级可见范围 ScopeToken
* - "ALL":全量可见(管理员)
* - "usr:{userId}:cls_scope"Redis Set 引用
*/
@Field()
classScopeToken!: string;
/**
* 学生可见范围 ScopeToken
*/
@Field()
studentScopeToken!: string;
}
@Resolver(() => UserDataScope)
export class DataScopeResolver {
private scopeTokenService: ScopeTokenService;
constructor(
private readonly iamService: IamService,
@Inject("REDIS_CLIENT") redis: Redis,
) {
this.scopeTokenService = new ScopeTokenService(redis);
}
/**
* Query: dataScope(userId) → UserDataScope
*
* Apollo Router 在执行跨子图查询时调用此方法获取 DataScope
* 然后通过 @requires 传给 core-edu 等子图。
*/
@Query(() => UserDataScope, { nullable: true })
async dataScope(
@Args("userId", { type: () => ID }) userId: string,
@Context() ctx: { graphqlContext: GraphqlContext },
): Promise<UserDataScope | null> {
// 权限校验:仅自身或管理员可查询 DataScope
const gqlCtx = ctx.graphqlContext;
if (gqlCtx.userId !== userId && !gqlCtx.isAdmin) {
return null;
}
// 计算可见班级 ID 列表
const visibleClassIds = await this.iamService.getVisibleClassIds(userId);
const classScopeToken = await this.scopeTokenService.issueScopeToken(
userId,
"cls_scope",
visibleClassIds,
);
// 计算可见学生 ID 列表
const visibleStudentIds =
await this.iamService.getVisibleStudentIds(userId);
const studentScopeToken = await this.scopeTokenService.issueScopeToken(
userId,
"stu_scope",
visibleStudentIds,
);
return {
userId,
classScopeToken,
studentScopeToken,
};
}
}

View File

@@ -0,0 +1,54 @@
/**
* iam Role Resolverv2.1 M1
*
* Apollo Federation 子图Role Entity
* - @key(fields: "roleId") 支持跨子图引用
* - @ResolveReference 使用 DataLoader 批量加载ADR-035
*/
import {
Resolver,
Query,
Args,
ID,
ResolveReference,
ObjectType,
Field,
Directive,
} from "@nestjs/graphql";
import { DataLoaderService, type RoleEntity } from "../dataloader.service.js";
@ObjectType()
@Directive(`@key(fields: "roleId")`)
export class Role {
@Field(() => ID)
roleId!: string;
@Field()
name!: string;
@Field({ nullable: true })
description: string | null = null;
@Field()
roleType!: string;
@Field()
level!: number;
}
@Resolver(() => Role)
export class RoleResolver {
constructor(private readonly loader: DataLoaderService) {}
@ResolveReference()
async resolveReference(ref: { roleId: string }): Promise<RoleEntity | null> {
return this.loader.roleLoader.load(ref.roleId);
}
@Query(() => Role, { nullable: true })
async role(
@Args("roleId", { type: () => ID }) roleId: string,
): Promise<RoleEntity | null> {
return this.loader.roleLoader.load(roleId);
}
}

View File

@@ -0,0 +1,69 @@
/**
* iam User Resolverv2.1 M1
*
* Apollo Federation 子图User Entity
* - @key(fields: "userId") 支持跨子图引用
* - @ResolveReference 使用 DataLoader 批量加载ADR-035
* - Query 入口供 Apollo Router 直接查询
*/
import {
Resolver,
Query,
Args,
ID,
ResolveReference,
ObjectType,
Field,
Directive,
} from "@nestjs/graphql";
import { DataLoaderService, type UserEntity } from "../dataloader.service.js";
/**
* User ObjectTypeFederation @key
* 对应 iam.users 表
*/
@ObjectType()
@Directive(`@key(fields: "userId")`)
export class User {
@Field(() => ID)
userId!: string;
@Field()
email!: string;
@Field()
name!: string;
@Field()
status!: string;
@Field()
dataScope!: string;
}
@Resolver(() => User)
export class UserResolver {
constructor(private readonly loader: DataLoaderService) {}
/**
* Federation Reference Resolver
*
* 当其他子图通过 @key 引用 User 时Router 调用此方法解析。
* 使用 DataLoader 批量加载,消除 N+1 查询ADR-035
*/
@ResolveReference()
async resolveReference(ref: { userId: string }): Promise<UserEntity | null> {
return this.loader.userLoader.load(ref.userId);
}
/**
* Query: user(userId) → User
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝ADR-036
*/
@Query(() => User, { nullable: true })
async user(
@Args("userId", { type: () => ID }) userId: string,
): Promise<UserEntity | null> {
return this.loader.userLoader.load(userId);
}
}

View File

@@ -0,0 +1,34 @@
/**
* iam RouterAuthGuard 包装v2.1 ADR-036
*
* 仅作用于 /graphql 端点REST 路由放行(已有 PermissionGuard
*/
import { Injectable, ExecutionContext } from "@nestjs/common";
import {
RouterAuthGuard as BaseRouterAuthGuard,
type RouterAuthConfig,
} from "@edu/shared-ts/federation";
@Injectable()
export class RouterAuthGuard extends BaseRouterAuthGuard {
constructor() {
const config: RouterAuthConfig = {
secret: process.env.ROUTER_AUTH_SECRET ?? "",
devMode: process.env.DEV_MODE === "true",
};
super(config);
}
override canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
return true;
}
return super.canActivate(ctx);
}
}

View File

@@ -20,9 +20,9 @@ import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.j
TokenBlacklistService,
IamGrpcController,
],
// 导出 PermissionCacheService IamRepository,供在 AppModule 级别注册的
// APP_GUARDPermissionGuard注入使用。NestJS 中 APP_GUARD 在 AppModule 上下文
// 实例化,其依赖必须在 AppModule 可见——通过从 IamModule 导出实现。
exports: [PermissionCacheService, IamRepository],
// 导出 IamService / IamRepository / PermissionCacheService
// - IamRepository 供 AppModule 的 APP_GUARDPermissionGuard注入
// - IamService / IamRepository 供 GraphqlModule 的 DataLoader / Resolver 注入v2.1
exports: [IamService, IamRepository, PermissionCacheService],
})
export class IamModule {}

View File

@@ -96,6 +96,15 @@ export class IamRepository {
return result.map((r) => r.iam_roles);
}
/**
* 批量查询角色DataLoader @key 解析器使用ADR-035
*/
async batchFindRoles(ids: string[]): Promise<Role[]> {
if (ids.length === 0) return [];
const db = getDb();
return db.select().from(roles).where(inArray(roles.id, ids));
}
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);

View File

@@ -651,6 +651,65 @@ export class IamService {
return user.dataScope;
}
/**
* 获取用户可见班级 ID 列表v2.1 DataScope / ScopeTokenADR-024 / ADR-041
*
* 基于 dataScope 级别计算:
* - ALL / SCHOOL返回空数组表示全量可见ScopeToken 返回 "ALL"
* - GRADE返回该用户所教年级的班级 ID 列表
* - CLASS返回该用户关联的班级 ID 列表
* - SELF返回空数组学生只看自己
*
* 注意:此处仅返回 ID 列表,实际业务逻辑可能需调用 core-edu 服务获取关联关系。
* v2.1 中通过 ScopeToken 存入 Redis Set避免在 GraphQL 联邦中传全量数组。
*/
async getVisibleClassIds(userId: string): Promise<string[]> {
const user = await this.repository.findUserById(userId);
if (!user) {
return [];
}
// ALL / SCHOOL 级别:全量可见
if (user.dataScope === "all" || user.dataScope === "school") {
return [];
}
// SELF学生只看自己无班级可见
if (user.dataScope === "self") {
return [];
}
// GRADE / CLASS / SUBJECT通过 viewport 查询关联班级
// 实际实现需 join role_viewports 表或其他业务表
// 此处先返回空数组占位,后续根据业务表结构补充
// TODO: 实现 GRADE/CLASS 级别的可见班级计算逻辑
return [];
}
/**
* 获取用户可见学生 ID 列表v2.1 DataScope / ScopeTokenADR-024 / ADR-041
*/
async getVisibleStudentIds(userId: string): Promise<string[]> {
const user = await this.repository.findUserById(userId);
if (!user) {
return [];
}
// ALL / SCHOOL 级别:全量可见
if (user.dataScope === "all" || user.dataScope === "school") {
return [];
}
// SELF学生只看自己
if (user.dataScope === "self") {
return [userId];
}
// GRADE / CLASS / SUBJECT通过 viewport 查询关联学生
// TODO: 实现 GRADE/CLASS 级别的可见学生计算逻辑
return [];
}
async getViewports(userId: string): Promise<ViewportItem[]> {
const viewports = await this.repository.getUserViewports(userId);
const permissions = await this.getEffectivePermissions(userId);