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:
107
services/iam/src/graphql/resolvers/datascope.resolver.ts
Normal file
107
services/iam/src/graphql/resolvers/datascope.resolver.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* iam DataScope Resolver(v2.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
services/iam/src/graphql/resolvers/role.resolver.ts
Normal file
54
services/iam/src/graphql/resolvers/role.resolver.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* iam Role Resolver(v2.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);
|
||||
}
|
||||
}
|
||||
69
services/iam/src/graphql/resolvers/user.resolver.ts
Normal file
69
services/iam/src/graphql/resolvers/user.resolver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* iam User Resolver(v2.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 ObjectType(Federation @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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user