feat(iam): 完整实现 iam 身份认证与权限服务

包含 jwt/jwks/audit/grpc、rbac、cache、redis/kafka 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:39 +08:00
parent 06e0f9139b
commit a35e759d64
32 changed files with 2589 additions and 778 deletions

View File

@@ -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-* 头部解析用户身份)
* - PermissionGuardAPP_GUARDDB 驱动 + 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<void> {
// 连接 Kafka producerOutbox 投递前置依赖)
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",
);
}
}

View File

@@ -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<void> {
if (pool) {
await pool.end();
pool = null;
dbInstance = null;
}
}

View File

@@ -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 RS256president §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"),
// KafkaOutbox 投递)
KAFKA_BROKERS: z.string(),
KAFKA_CLIENT_ID: z.string().default("iam-service"),
// gRPC serverI1 裁决:端口 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<typeof envSchema>;
@@ -17,8 +41,11 @@ export type Env = z.infer<typeof envSchema>;
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;
}

View File

@@ -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 开销。
* kidKey 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];
}

View File

@@ -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<void> {
const p = getKafkaProducer();
await p.connect();
}
export async function disconnectKafkaProducer(): Promise<void> {
if (producer) {
await producer.disconnect();
producer = null;
}
}
/**
* IAM Kafka topic 路由coord-final-decisions I5 + iam_contract §1.4)。
*
* 事件命名规则:`<Aggregate>.<Action>`
* - 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;

View File

@@ -0,0 +1,24 @@
import { Redis } from "ioredis";
import { env } from "./env.js";
type RedisClient = InstanceType<typeof Redis>;
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<void> {
if (client) {
await client.quit();
client = null;
}
}

View File

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

View File

@@ -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/iamI7 裁决REST 路径统一加 /v1 前缀)
* gateway 路由:/iam/v1/* → iam /v1/iam/*(透传不改路径)
*
* 公开端点register / login / refresh / jwksJwksController
* 鉴权端点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 };
}
}

View File

@@ -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<typeof registerSchema>;
export type LoginDto = z.infer<typeof loginSchema>;
export type LogoutDto = z.infer<typeof logoutSchema>;

View File

@@ -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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
const user = await this.service.getUserInfo(data.userId);
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "BatchGetUsers")
async batchGetUsers(data: { userIds: string[] }): Promise<unknown> {
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<unknown> {
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<unknown> {
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<string, unknown> {
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<string, unknown> {
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
};
}
}

View File

@@ -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 {}

View File

@@ -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<User> {
const db = getDb();
await db.insert(users).values(data);
@@ -43,6 +63,27 @@ export class IamRepository {
return result;
}
async batchFindUsers(ids: string[]): Promise<User[]> {
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<void> {
const db = getDb();
await db.update(users).set({ status }).where(eq(users.id, userId));
}
async updatePassword(userId: string, passwordHash: string): Promise<void> {
const db = getDb();
await db
.update(users)
.set({ passwordHash, passwordChangedAt: new Date() })
.where(eq(users.id, userId));
}
// ============ 角色 ============
async getUserRoles(userId: string): Promise<Role[]> {
const db = getDb();
const result = await db
@@ -53,6 +94,31 @@ export class IamRepository {
return result.map((r) => r.iam_roles);
}
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async findRoleByName(name: string): Promise<Role | undefined> {
const db = getDb();
const [result] = await db.select().from(roles).where(eq(roles.name, name));
return result;
}
async assignRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db.insert(userRoles).values({ userId, roleId });
}
async revokeRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)));
}
// ============ 权限 ============
async getUserPermissions(userId: string): Promise<Permission[]> {
const db = getDb();
const userRoleRows = await db
@@ -72,6 +138,13 @@ export class IamRepository {
return result.map((r) => r.iam_permissions);
}
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
// ============ 视口 ============
async getUserViewports(userId: string): Promise<RoleViewport[]> {
const db = getDb();
const userRoleRows = await db
@@ -86,40 +159,43 @@ export class IamRepository {
.where(inArray(roleViewports.roleId, roleIds));
}
async getUserDataScope(userId: string): Promise<string> {
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<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
async assignRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db.insert(userRoles).values({ userId, roleId });
}
// ============ 刷新令牌 ============
async createRefreshToken(data: {
id: string;
userId: string;
tokenHash: string;
jti?: string;
expiresAt: Date;
}): Promise<void> {
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<void> {
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<void> {
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<StudentGuardian> {
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<void> {
const db = getDb();
await db.insert(userAuditLog).values(data);
}
async queryAuditLog(options: {
actorUserId?: string;
resourceType?: string;
resourceId?: string;
limit?: number;
offset?: number;
}): Promise<AuditLog[]> {
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<string[]> {
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<void> {
const db = getDb();
const id = crypto.randomUUID();
await db.insert(passwordHistory).values({ id, userId, passwordHash });
}
}

View File

@@ -4,40 +4,79 @@ import {
char,
timestamp,
text,
int,
mysqlEnum,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// DataScope 6 级president §3.2SUBJECT 替代 DISTRICT
export const DATA_SCOPES = [
"self",
"subject",
"class",
"grade",
"school",
"all",
] as const;
export type DataScope = (typeof DATA_SCOPES)[number];
// 三层角色模型P2.2system / 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;

View File

@@ -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 事件发布I5UserEvent / 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<TokenPair> {
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<void> {
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<UserInfo> {
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<string[]> {
const perms = await this.repository.getUserPermissions(userId);
const unique = new Set(perms.map((p) => p.name));
return [...unique];
async batchGetUsers(userIds: string[]): Promise<UserInfo[]> {
const users = await this.repository.batchFindUsers(userIds);
return Promise.all(users.map((u) => this.buildUserInfo(u)));
}
async getUserViewports(userId: string): Promise<ViewportItem[]> {
// ============ 权限与视口类 ============
async getEffectivePermissions(userId: string): Promise<string[]> {
// 先查缓存
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<string> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError("User", userId);
}
return user.dataScope;
}
async getViewports(userId: string): Promise<ViewportItem[]> {
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<Role[]> {
// ============ 密钥与关系类 ============
getPublicKey(): { kid: string; alg: string; publicKeyPem: string } {
return this.jwksService.getPublicKeyPem();
}
async getChildrenByParent(parentId: string): Promise<ChildInfo[]> {
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<Permission[]> {
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<void> {
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 },
);
}
}

View File

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

View File

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

View File

@@ -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/iamI7 裁决)
*/
@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 };
}

View File

@@ -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 serverenv.PORT3002供 gateway 透传 + admin-portal 直连
* - gRPC serverenv.GRPC_PORT50052供 BFF 聚合调用I1 裁决)
*
* 启动顺序:
* 1. initTracerOTel SDK
* 2. 创建 NestApplication
* 3. 注册 GlobalErrorFilter
* 4. 启动 gRPC microservicehybrid app
* 5. 启动 HTTP server
* 6. 预加载 JWT 密钥对(确保文件可读)
*/
async function bootstrap(): Promise<void> {
initTracer();
// 预加载 JWT 密钥对(启动时即校验文件可读,避免运行时才发现配置错误)
getJwtKeyPair();
const app = await NestFactory.create(AppModule, {
logger: ["log", "error", "warn"],
});
@@ -18,15 +38,30 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
// gRPC microservice端口 50052I1 裁决)
app.connectMicroservice<MicroserviceOptions>({
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 appHTTP + 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();

View File

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

View File

@@ -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 种子数据)。
*
* 权限名格式:`<resource>:<action>`(如 `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<string, Permission[]> = {
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. 未命中则查 DBrole_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<boolean> {
if (process.env.DEV_MODE === "true") {
return true;
}
@@ -57,15 +64,33 @@ export class PermissionGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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<string[]> {
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;
}
}

View File

@@ -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<string[] | null> {
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<void> {
const redis = getRedis();
await redis.set(
PermissionCacheService.buildKey(userId),
JSON.stringify(permissions),
"EX",
PERMISSION_CACHE_TTL_SECONDS,
);
}
async invalidate(userId: string): Promise<void> {
const redis = getRedis();
await redis.del(PermissionCacheService.buildKey(userId));
}
}

View File

@@ -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<boolean> {
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<void> {
if (ttlSeconds <= 0) return; // 已过期,无需加入
const redis = getRedis();
await redis.set(TokenBlacklistService.buildKey(jti), "1", "EX", ttlSeconds);
}
}

View File

@@ -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 /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 DB 连接,失败返回 503
*
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
* - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 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<DependencyCheck> {
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<DependencyCheck> {
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<DependencyCheck> {
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),
};
}
}
}

View File

@@ -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 / SIGINTNestJS 会依次调用 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 ORMMySQL无 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");