feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -8,6 +8,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type { StudentAccessScope } from './student-access-scope';
@Injectable()
export class StudentsService {
@@ -224,7 +225,7 @@ export class StudentsService {
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates as Partial<Student>);
await this.repo.update(student.id, updates);
matched++;
}
return {
@@ -302,4 +303,228 @@ export class StudentsService {
return { student, enrollments: comparison };
}
// -------------------------------------------------------------------------
// Agent-safe query APIs — SQL-level scope + field whitelist
// -------------------------------------------------------------------------
/**
* Whitelisted output type for agent student searches.
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
*/
private static readonly AGENT_STUDENT_SELECT = [
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
] as const;
/**
* Search students with SQL-enforced scope, field whitelist, and limit.
*
* @param scope — data-range discriminator (manageAll or teacher).
* @param query — optional keyword, classId, organizationId, limit.
* @returns formatted whitelist-only results with classIds.
*/
async agentSearchStudents(
scope: StudentAccessScope,
query?: {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
},
): Promise<
{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
}[]
> {
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
const qb = this.repo
.createQueryBuilder('student')
.distinct(true)
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization');
// ---- Scope enforcement ----
this.applyStudentScope(qb, scope, query?.classId);
// ---- Filters ----
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
}
if (query?.organizationId) {
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
const rows: Record<string, unknown>[] = await qb.getRawMany();
if (rows.length === 0) return [];
// Second bounded query: classIds only for the returned student ids.
// For teacher scope, the class filter MUST be re-applied so the
// teacher only sees classIds they are assigned to.
const studentIds = rows.map((r) => r.student_id as number);
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.studentId IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
const classMap = new Map<number, number[]>();
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
const sid = cr.cs_student_id;
if (!classMap.has(sid)) classMap.set(sid, []);
classMap.get(sid)!.push(cr.cs_class_id);
}
return rows.map((r) => ({
id: r.student_id as number,
name: r.student_name as string,
studentNo: (r.student_student_no as string) ?? '',
gender: (r.student_gender as string) ?? '',
status: r.student_status as string,
organizationId: r.student_organization_id as number,
organizationName: (r.organization_name as string) ?? '',
classIds: classMap.get(r.student_id as number) ?? [],
}));
}
/**
* Get single student basic info with SQL-enforced scope + whitelist.
* Returns `null` for students out of scope or non-existent (no leak).
*/
async agentGetStudentBasic(
scope: StudentAccessScope,
studentId: number,
): Promise<{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
} | null> {
const qb = this.repo
.createQueryBuilder('student')
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization')
.where('student.id = :studentId', { studentId });
this.applyStudentScope(qb, scope);
const row = await qb.getRawOne();
if (!row) return null;
// For teacher scope, re-apply class filter so teacher only sees
// classIds they are assigned to (not ALL active classIds of the student).
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.studentId = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
return {
id: row.student_id as number,
name: row.student_name as string,
studentNo: (row.student_student_no as string) ?? '',
gender: (row.student_gender as string) ?? '',
status: row.student_status as string,
organizationId: row.student_organization_id as number,
organizationName: (row.organization_name as string) ?? '',
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
};
}
/**
* Apply data-range scope to a student QueryBuilder.
*
* - `manageAll`: no restriction.
* - `teacher`: INNER JOIN ClassStudent → active students in the
* teacher's assigned classes (via ClassTeacher).
* - When `classId` is provided, it is ANDed with the scope
* (intersection) — the model cannot widen access.
*/
private applyStudentScope(
qb: ReturnType<typeof this.repo.createQueryBuilder>,
scope: StudentAccessScope,
classId?: number,
): void {
if (scope.type === 'manageAll') {
if (classId != null) {
qb.innerJoin(
'class_student',
'cs_scope',
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
{ scopeClassId: classId, scopeCsStatus: 'active' },
);
}
return;
}
// Teacher scope: active students in teacher's assigned classes
const teacherClause =
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
scopeTeacherUserId: scope.userId,
scopeCsStatus: 'active',
});
if (classId != null) {
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
}
}
}