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:
@@ -12,11 +12,14 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apollo/subgraph": "^2.2.3",
|
||||||
"@edu/shared-ts": "workspace:*",
|
"@edu/shared-ts": "workspace:*",
|
||||||
"@grpc/grpc-js": "^1.12.0",
|
"@grpc/grpc-js": "^1.12.0",
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
|
"@nestjs/apollo": "^12.2.0",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
|
"@nestjs/graphql": "^12.2.0",
|
||||||
"@nestjs/microservices": "^10.4.0",
|
"@nestjs/microservices": "^10.4.0",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
@@ -24,7 +27,9 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||||
"@opentelemetry/sdk-node": "^0.53.0",
|
"@opentelemetry/sdk-node": "^0.53.0",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"dataloader": "^2.2.2",
|
||||||
"drizzle-orm": "^0.31.0",
|
"drizzle-orm": "^0.31.0",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { HealthModule } from "./shared/health/health.module.js";
|
|||||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||||
import { AuthMiddleware } from "./middleware/auth.middleware.js";
|
import { AuthMiddleware } from "./middleware/auth.middleware.js";
|
||||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.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 { OutboxModule } from "@edu/shared-ts/outbox";
|
||||||
import { getDbInstance } from "./config/database.js";
|
import { getDbInstance } from "./config/database.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,19 +22,22 @@ import {
|
|||||||
} from "./config/kafka.js";
|
} from "./config/kafka.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IAM 根模块。
|
* IAM 根模块(v2.1)。
|
||||||
*
|
*
|
||||||
* 装配:
|
* 装配:
|
||||||
* - IamModule(业务)
|
* - IamModule(业务)
|
||||||
* - HealthModule(健康检查)
|
* - HealthModule(健康检查)
|
||||||
* - OutboxModule(事务性事件发布,I5 裁决)
|
* - GraphqlModule(Apollo Federation 子图,v2.1 新增)
|
||||||
|
* - OutboxModule(事务性事件发布,I5 裁决;v2.1 后投递由 Debezium 完成)
|
||||||
* - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份)
|
* - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份)
|
||||||
* - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存,I3 裁决)
|
* - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存,I3 裁决)
|
||||||
|
* - RouterAuthGuard(APP_GUARD,仅 /graphql 端点生效,ADR-036)
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
IamModule,
|
IamModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
GraphqlModule,
|
||||||
OutboxModule.forRoot({
|
OutboxModule.forRoot({
|
||||||
config: {
|
config: {
|
||||||
tableName: "iam_outbox",
|
tableName: "iam_outbox",
|
||||||
@@ -48,6 +53,7 @@ import {
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RouterAuthGuard },
|
||||||
LifecycleService,
|
LifecycleService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -56,6 +62,8 @@ export class AppModule implements NestModule, OnModuleInit {
|
|||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
// 连接 Kafka producer(Outbox 投递前置依赖)
|
// 连接 Kafka producer(Outbox 投递前置依赖)
|
||||||
|
// v2.1 注:OutboxPublisher 轮询线程将被废弃(M8),由 Debezium 接管投递
|
||||||
|
// 此处保留 Kafka producer 连接用于其他场景(如直接发事件)
|
||||||
try {
|
try {
|
||||||
await connectKafkaProducer();
|
await connectKafkaProducer();
|
||||||
this.logger.log("Kafka producer connected");
|
this.logger.log("Kafka producer connected");
|
||||||
|
|||||||
97
services/iam/src/graphql/dataloader.service.ts
Normal file
97
services/iam/src/graphql/dataloader.service.ts
Normal 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 Entity(GraphQL 输出类型)
|
||||||
|
* 对应 iam.users 表
|
||||||
|
*/
|
||||||
|
export interface UserEntity {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
dataScope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role Entity(GraphQL 输出类型)
|
||||||
|
* 对应 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
services/iam/src/graphql/generated/schema.graphql
Normal file
187
services/iam/src/graphql/generated/schema.graphql
Normal 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
|
||||||
|
}
|
||||||
59
services/iam/src/graphql/graphql.module.ts
Normal file
59
services/iam/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* iam GraphQL 子图模块(v2.1 M1)
|
||||||
|
*
|
||||||
|
* Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。
|
||||||
|
*
|
||||||
|
* 强制约束:
|
||||||
|
* - @key 解析器必须使用 DataLoader(ADR-035)
|
||||||
|
* - RouterAuthGuard 校验 Router-Authorization Header(ADR-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 {}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
services/iam/src/graphql/router-auth.guard.ts
Normal file
34
services/iam/src/graphql/router-auth.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@ import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.j
|
|||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
IamGrpcController,
|
IamGrpcController,
|
||||||
],
|
],
|
||||||
// 导出 PermissionCacheService 与 IamRepository,供在 AppModule 级别注册的
|
// 导出 IamService / IamRepository / PermissionCacheService:
|
||||||
// APP_GUARD(PermissionGuard)注入使用。NestJS 中 APP_GUARD 在 AppModule 上下文
|
// - IamRepository 供 AppModule 的 APP_GUARD(PermissionGuard)注入
|
||||||
// 实例化,其依赖必须在 AppModule 可见——通过从 IamModule 导出实现。
|
// - IamService / IamRepository 供 GraphqlModule 的 DataLoader / Resolver 注入(v2.1)
|
||||||
exports: [PermissionCacheService, IamRepository],
|
exports: [IamService, IamRepository, PermissionCacheService],
|
||||||
})
|
})
|
||||||
export class IamModule {}
|
export class IamModule {}
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ export class IamRepository {
|
|||||||
return result.map((r) => r.iam_roles);
|
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[]> {
|
async getAllRoles(): Promise<Role[]> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
return db.select().from(roles);
|
return db.select().from(roles);
|
||||||
|
|||||||
@@ -651,6 +651,65 @@ export class IamService {
|
|||||||
return user.dataScope;
|
return user.dataScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户可见班级 ID 列表(v2.1 DataScope / ScopeToken,ADR-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 / ScopeToken,ADR-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[]> {
|
async getViewports(userId: string): Promise<ViewportItem[]> {
|
||||||
const viewports = await this.repository.getUserViewports(userId);
|
const viewports = await this.repository.getUserViewports(userId);
|
||||||
const permissions = await this.getEffectivePermissions(userId);
|
const permissions = await this.getEffectivePermissions(userId);
|
||||||
|
|||||||
Reference in New Issue
Block a user