Skip to content

Commit 38001cf

Browse files
committed
feat: add role-based and resource-level permission enforcement
1 parent 3c66000 commit 38001cf

17 files changed

Lines changed: 635 additions & 143 deletions

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { APP_FILTER, APP_GUARD } from '@nestjs/core';
33
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
44
import { LoggerModule } from 'nestjs-pino';
55
import { AppController } from './app.controller';
6+
import { AccessControlModule } from './common/access/access-control.module';
67
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
78
import { CacheModule } from './common/cache/cache.module';
89
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
@@ -41,6 +42,7 @@ import { UsersModule } from './modules/users/users.module';
4142
}),
4243
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
4344
PrismaModule,
45+
AccessControlModule,
4446
CacheModule,
4547
JobsModule,
4648
MailerModule,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Global, Module } from '@nestjs/common';
2+
import { AccessControlService } from './access-control.service';
3+
4+
/**
5+
* Global module exposing {@link AccessControlService} to every feature module
6+
* without an explicit import, mirroring how {@link PrismaModule} is wired.
7+
*/
8+
@Global()
9+
@Module({
10+
providers: [AccessControlService],
11+
exports: [AccessControlService],
12+
})
13+
export class AccessControlModule {}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { ForbiddenException, Injectable } from '@nestjs/common';
2+
import type { UserRole } from '@opennota/shared';
3+
import type { JwtPayload } from '../auth/jwt-payload';
4+
import { PrismaService } from '../prisma/prisma.service';
5+
6+
/**
7+
* Central authority for resource-level (ownership) permission checks.
8+
*
9+
* Coarse role gating lives in `@Roles(...)` metadata enforced by the
10+
* {@link RolesGuard}. This service answers the finer questions a role alone
11+
* cannot: *which* subjects a teacher may grade, *which* students' academic
12+
* data a user may read. Keeping that logic here means every module enforces
13+
* the same rules instead of re-deriving them.
14+
*/
15+
@Injectable()
16+
export class AccessControlService {
17+
constructor(private readonly prisma: PrismaService) {}
18+
19+
/** Staff (ADMIN, PRINCIPAL) are unscoped: they may act on any resource. */
20+
isStaff(role: UserRole): boolean {
21+
return role === 'ADMIN' || role === 'PRINCIPAL';
22+
}
23+
24+
/** TeacherProfile-subject ids the teacher (identified by user id) teaches. */
25+
async teacherSubjectIds(userId: string): Promise<string[]> {
26+
const rows = await this.prisma.teacherSubject.findMany({
27+
where: { teacher: { userId } },
28+
select: { subjectId: true },
29+
});
30+
return rows.map((row) => row.subjectId);
31+
}
32+
33+
/**
34+
* Whether the user may manage (grade, evaluate, weight) the subject. Staff
35+
* always may; a teacher only for subjects they are assigned to.
36+
*/
37+
async canManageSubject(user: JwtPayload, subjectId: string): Promise<boolean> {
38+
if (this.isStaff(user.role)) {
39+
return true;
40+
}
41+
if (user.role !== 'TEACHER') {
42+
return false;
43+
}
44+
const assignment = await this.prisma.teacherSubject.findFirst({
45+
where: { subjectId, teacher: { userId: user.sub } },
46+
});
47+
return assignment !== null;
48+
}
49+
50+
/** Throws {@link ForbiddenException} unless {@link canManageSubject} holds. */
51+
async assertCanManageSubject(user: JwtPayload, subjectId: string): Promise<void> {
52+
if (!(await this.canManageSubject(user, subjectId))) {
53+
throw new ForbiddenException('You are not assigned to this subject');
54+
}
55+
}
56+
57+
/**
58+
* Whether the user may read the given student's academic data:
59+
* - staff: any student;
60+
* - teacher: students enrolled in a class group they teach a subject in;
61+
* - student: only themselves;
62+
* - guardian: only their linked students.
63+
*/
64+
async canViewStudent(user: JwtPayload, studentId: string): Promise<boolean> {
65+
if (this.isStaff(user.role)) {
66+
return true;
67+
}
68+
if (user.role === 'STUDENT') {
69+
const profile = await this.prisma.studentProfile.findUnique({
70+
where: { id: studentId },
71+
select: { userId: true },
72+
});
73+
return profile?.userId === user.sub;
74+
}
75+
if (user.role === 'GUARDIAN') {
76+
const link = await this.prisma.studentGuardian.findFirst({
77+
where: { studentId, guardian: { userId: user.sub } },
78+
});
79+
return link !== null;
80+
}
81+
if (user.role === 'TEACHER') {
82+
const enrollment = await this.prisma.enrollment.findFirst({
83+
where: {
84+
studentId,
85+
isActive: true,
86+
classGroup: {
87+
subjects: {
88+
some: {
89+
deletedAt: null,
90+
teacherSubjects: { some: { teacher: { userId: user.sub } } },
91+
},
92+
},
93+
},
94+
},
95+
});
96+
return enrollment !== null;
97+
}
98+
return false;
99+
}
100+
101+
/** Throws {@link ForbiddenException} unless {@link canViewStudent} holds. */
102+
async assertCanViewStudent(user: JwtPayload, studentId: string): Promise<void> {
103+
if (!(await this.canViewStudent(user, studentId))) {
104+
throw new ForbiddenException('You do not have permission to view this student');
105+
}
106+
}
107+
108+
/**
109+
* StudentProfile ids a teacher may read: every active enrollment in a class
110+
* group where the teacher teaches at least one subject.
111+
*/
112+
async teacherStudentIds(userId: string): Promise<string[]> {
113+
const enrollments = await this.prisma.enrollment.findMany({
114+
where: {
115+
isActive: true,
116+
classGroup: {
117+
subjects: {
118+
some: {
119+
deletedAt: null,
120+
teacherSubjects: { some: { teacher: { userId } } },
121+
},
122+
},
123+
},
124+
},
125+
select: { studentId: true },
126+
});
127+
return [...new Set(enrollments.map((enrollment) => enrollment.studentId))];
128+
}
129+
}

apps/api/src/modules/evaluations/evaluations.controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,17 @@ export class EvaluationsController {
2929

3030
@Get()
3131
list(
32+
@CurrentUser() user: JwtPayload,
3233
@Query('subjectId') subjectId?: string,
3334
@Query('termId') termId?: string,
3435
@Query('classGroupId') classGroupId?: string,
3536
) {
36-
return this.evaluationsService.list({ subjectId, termId, classGroupId });
37+
return this.evaluationsService.list(user, { subjectId, termId, classGroupId });
3738
}
3839

3940
@Get(':id')
40-
findOne(@Param('id') id: string) {
41-
return this.evaluationsService.findOne(id);
41+
findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
42+
return this.evaluationsService.getOne(user, id);
4243
}
4344

4445
@Post()

apps/api/src/modules/evaluations/evaluations.service.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@nestjs/common';
77
import type { Evaluation } from '@opennota/db';
88
import type { CreateEvaluationInput, UpdateEvaluationInput } from '@opennota/shared';
9+
import { AccessControlService } from '../../common/access/access-control.service';
910
import type { JwtPayload } from '../../common/auth/jwt-payload';
1011
import { PrismaService } from '../../common/prisma/prisma.service';
1112

@@ -17,13 +18,25 @@ interface EvaluationFilter {
1718

1819
@Injectable()
1920
export class EvaluationsService {
20-
constructor(private readonly prisma: PrismaService) {}
21+
constructor(
22+
private readonly prisma: PrismaService,
23+
private readonly access: AccessControlService,
24+
) {}
2125

22-
list(filter: EvaluationFilter): Promise<Evaluation[]> {
26+
/** Lists evaluations. Teachers see only their assigned subjects' evaluations. */
27+
async list(user: JwtPayload, filter: EvaluationFilter): Promise<Evaluation[]> {
28+
let subjectId: string | { in: string[] } | undefined = filter.subjectId;
29+
if (user.role === 'TEACHER') {
30+
if (filter.subjectId !== undefined) {
31+
await this.access.assertCanManageSubject(user, filter.subjectId);
32+
} else {
33+
subjectId = { in: await this.access.teacherSubjectIds(user.sub) };
34+
}
35+
}
2336
return this.prisma.evaluation.findMany({
2437
where: {
2538
deletedAt: null,
26-
subjectId: filter.subjectId,
39+
subjectId,
2740
termId: filter.termId,
2841
...(filter.classGroupId ? { subject: { classGroupId: filter.classGroupId } } : {}),
2942
},
@@ -41,8 +54,15 @@ export class EvaluationsService {
4154
return evaluation;
4255
}
4356

57+
/** Loads an evaluation, asserting the user may access its subject. */
58+
async getOne(user: JwtPayload, id: string): Promise<Evaluation> {
59+
const evaluation = await this.findOne(id);
60+
await this.access.assertCanManageSubject(user, evaluation.subjectId);
61+
return evaluation;
62+
}
63+
4464
async create(user: JwtPayload, input: CreateEvaluationInput): Promise<Evaluation> {
45-
await this.assertCanManageSubject(user, input.subjectId);
65+
await this.access.assertCanManageSubject(user, input.subjectId);
4666
const teacherId = await this.resolveTeacherId(user, input.subjectId);
4767
return this.prisma.evaluation.create({
4868
data: {
@@ -65,7 +85,7 @@ export class EvaluationsService {
6585

6686
async update(user: JwtPayload, id: string, input: UpdateEvaluationInput): Promise<Evaluation> {
6787
const evaluation = await this.findOne(id);
68-
await this.assertCanManageSubject(user, evaluation.subjectId);
88+
await this.access.assertCanManageSubject(user, evaluation.subjectId);
6989
return this.prisma.evaluation.update({
7090
where: { id },
7191
data: {
@@ -85,7 +105,7 @@ export class EvaluationsService {
85105

86106
async remove(user: JwtPayload, id: string): Promise<void> {
87107
const evaluation = await this.findOne(id);
88-
await this.assertCanManageSubject(user, evaluation.subjectId);
108+
await this.access.assertCanManageSubject(user, evaluation.subjectId);
89109
await this.prisma.evaluation.update({ where: { id }, data: { deletedAt: new Date() } });
90110
}
91111

@@ -111,16 +131,4 @@ export class EvaluationsService {
111131
}
112132
return assignment.teacherId;
113133
}
114-
115-
private async assertCanManageSubject(user: JwtPayload, subjectId: string): Promise<void> {
116-
if (user.role === 'ADMIN' || user.role === 'PRINCIPAL') {
117-
return;
118-
}
119-
const assignment = await this.prisma.teacherSubject.findFirst({
120-
where: { subjectId, teacher: { userId: user.sub } },
121-
});
122-
if (!assignment) {
123-
throw new ForbiddenException('You are not assigned to this subject');
124-
}
125-
}
126134
}

apps/api/src/modules/evaluations/grading-weights.controller.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ export class GradingWeightsController {
1515
constructor(private readonly gradingWeightsService: GradingWeightsService) {}
1616

1717
@Get()
18-
get(@Query('subjectId') subjectId?: string, @Query('termId') termId?: string) {
19-
return this.gradingWeightsService.get(subjectId, termId);
18+
get(
19+
@CurrentUser() user: JwtPayload,
20+
@Query('subjectId') subjectId?: string,
21+
@Query('termId') termId?: string,
22+
) {
23+
return this.gradingWeightsService.get(user, subjectId, termId);
2024
}
2125

2226
@Put()
Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
1+
import { BadRequestException, Injectable } from '@nestjs/common';
22
import type { GradingWeightConfig } from '@opennota/db';
33
import type { CreateGradingWeightConfigInput } from '@opennota/shared';
4+
import { AccessControlService } from '../../common/access/access-control.service';
45
import type { JwtPayload } from '../../common/auth/jwt-payload';
56
import { PrismaService } from '../../common/prisma/prisma.service';
67

78
@Injectable()
89
export class GradingWeightsService {
9-
constructor(private readonly prisma: PrismaService) {}
10+
constructor(
11+
private readonly prisma: PrismaService,
12+
private readonly access: AccessControlService,
13+
) {}
1014

1115
/** Returns the weight config for a subject/term, or null when none is set. */
12-
get(
16+
async get(
17+
user: JwtPayload,
1318
subjectId: string | undefined,
1419
termId: string | undefined,
1520
): Promise<GradingWeightConfig | null> {
1621
if (!subjectId || !termId) {
1722
throw new BadRequestException('Both subjectId and termId query parameters are required');
1823
}
24+
await this.access.assertCanManageSubject(user, subjectId);
1925
return this.prisma.gradingWeightConfig.findUnique({
2026
where: { subjectId_termId: { subjectId, termId } },
2127
});
@@ -25,7 +31,7 @@ export class GradingWeightsService {
2531
user: JwtPayload,
2632
input: CreateGradingWeightConfigInput,
2733
): Promise<GradingWeightConfig> {
28-
await this.assertCanManageSubject(user, input.subjectId);
34+
await this.access.assertCanManageSubject(user, input.subjectId);
2935
const weights = {
3036
examWeight: input.examWeight,
3137
assignmentWeight: input.assignmentWeight,
@@ -39,16 +45,4 @@ export class GradingWeightsService {
3945
update: weights,
4046
});
4147
}
42-
43-
private async assertCanManageSubject(user: JwtPayload, subjectId: string): Promise<void> {
44-
if (user.role === 'ADMIN' || user.role === 'PRINCIPAL') {
45-
return;
46-
}
47-
const assignment = await this.prisma.teacherSubject.findFirst({
48-
where: { subjectId, teacher: { userId: user.sub } },
49-
});
50-
if (!assignment) {
51-
throw new ForbiddenException('You are not assigned to this subject');
52-
}
53-
}
5448
}

apps/api/src/modules/grades/grades.controller.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@ export class GradesController {
1717
constructor(private readonly gradesService: GradesService) {}
1818

1919
@Get()
20-
list(@Query('evaluationId') evaluationId?: string) {
21-
return this.gradesService.listByEvaluation(evaluationId);
20+
list(@CurrentUser() user: JwtPayload, @Query('evaluationId') evaluationId?: string) {
21+
return this.gradesService.listByEvaluation(user, evaluationId);
2222
}
2323

2424
/** Students-by-evaluations matrix backing the grade entry sheet. */
2525
@Get('sheet')
26-
sheet(@Query('subjectId') subjectId?: string, @Query('termId') termId?: string) {
27-
return this.gradesService.getGradeSheet(subjectId, termId);
26+
sheet(
27+
@CurrentUser() user: JwtPayload,
28+
@Query('subjectId') subjectId?: string,
29+
@Query('termId') termId?: string,
30+
) {
31+
return this.gradesService.getGradeSheet(user, subjectId, termId);
2832
}
2933

3034
@Post()

0 commit comments

Comments
 (0)