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

@@ -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);
}
}