feat: add CampusScope request-level provider for data isolation

This commit is contained in:
2026-07-05 23:50:22 +08:00
parent ca53f9471b
commit abcfbc6121
3 changed files with 89 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
@@ -54,6 +54,8 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo
import { SyncModule } from './sync/sync.module';
import { NotificationsModule } from './notifications/notifications.module';
import { DepartmentsModule } from './departments/departments.module';
import { CampusScope } from './common/campus-scope';
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
@Module({
imports: [
@@ -139,9 +141,14 @@ import { DepartmentsModule } from './departments/departments.module';
DepartmentsModule,
],
providers: [
CampusScope,
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CampusScopeMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,13 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { CampusScope, CampusRequest } from './campus-scope';
@Injectable()
export class CampusScopeMiddleware implements NestMiddleware {
constructor(private readonly scope: CampusScope) {}
use(req: Request, _res: Response, next: NextFunction): void {
(req as CampusRequest).campusScope = this.scope;
next();
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { In } from 'typeorm';
import { Request } from 'express';
import { DepartmentsService } from '../departments/departments.service';
export interface CampusRequest extends Request {
user?: {
id: number;
isSuperAdmin?: boolean;
};
campusScope?: CampusScope;
}
@Injectable({ scope: Scope.REQUEST })
export class CampusScope {
constructor(
@Inject(REQUEST) private req: CampusRequest,
private departmentsService: DepartmentsService,
) {}
get userId(): number | undefined {
return this.req.user?.id;
}
get isSuperAdmin(): boolean {
return this.req.user?.isSuperAdmin ?? false;
}
get currentDepartmentId(): number | null {
const raw = this.req.headers?.['x-campus-id'];
if (raw === undefined) return null;
const str = Array.isArray(raw) ? raw[0] : raw;
if (!str) return null;
const id = parseInt(str, 10);
return Number.isNaN(id) ? null : id;
}
/** Appends departmentId filter to TypeORM find where conditions */
async filter<T extends Record<string, unknown>>(where: T): Promise<T> {
// Super admin with no campus selected → no filtering
if (this.isSuperAdmin && !this.currentDepartmentId) {
return where;
}
const ids = await this.getEffectiveScopeIds();
if (ids.length === 0) return where;
return { ...where, departmentId: In(ids) } as unknown as T;
}
private async getEffectiveScopeIds(): Promise<number[]> {
// Specific campus selected → campus + descendants
if (this.currentDepartmentId) {
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
}
// No campus selected → all user departments + descendants
if (!this.userId) return [];
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
const allIds = await Promise.all(
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
);
return [...new Set(allIds.flat())];
}
}