Skip to content
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"@aws-sdk/client-ssm": "^3.988.0",
"@aws-sdk/cloudfront-signer": "^3.988.0",
"@aws-sdk/s3-request-presigner": "^3.984.0",
"@casl/ability": "^6.8.0",
"@casl/prisma": "^1.6.1",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@sentry/node": "^10.38.0",
Expand Down
54 changes: 54 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/casl/ability.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { PureAbility } from '@casl/ability';
import type { PrismaQuery } from '@casl/prisma';
import type { Action } from './actions.js';
import type { AppSubjects } from './subjects.js';

export type AppAbility = PureAbility<[Action, AppSubjects], PrismaQuery>;
11 changes: 11 additions & 0 deletions src/casl/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const Action = {
Create: 'create',
Read: 'read',
Update: 'update',
Delete: 'delete',
UpdateStatus: 'updateStatus',
List: 'list',
Manage: 'manage',
} as const;

export type Action = (typeof Action)[keyof typeof Action];
148 changes: 148 additions & 0 deletions src/casl/student-post.ability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { subject } from '@casl/ability';
import { defineStudentPostAbility } from './student-post.ability.js';
import { Action } from './actions.js';
import { UserType } from '../constants/auth.constant.js';
import { AuthorRole } from '../constants/posts.constant.js';

describe('StudentPost Ability', () => {
describe('STUDENT ์—ญํ• ', () => {
const ability = defineStudentPostAbility({
userType: UserType.STUDENT,
profileId: 'student-1',
enrollmentIds: ['enrollment-1', 'enrollment-2'],
});

it('์ž๊ธฐ enrollment์˜ STUDENT ๊ธ€์„ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(true);
});

it('ํƒ€์ธ enrollment์˜ ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'other-enrollment',
authorRole: AuthorRole.STUDENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});

it('PARENT๊ฐ€ ์ž‘์„ฑํ•œ ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.PARENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});

it('๊ธ€์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค (์ž๊ธฐ enrollment)', () => {
const post = subject('StudentPost', {
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Delete, post)).toBe(true);
});
});

describe('INSTRUCTOR ์—ญํ• ', () => {
const ability = defineStudentPostAbility({
userType: UserType.INSTRUCTOR,
profileId: 'inst-1',
effectiveInstructorId: 'inst-1',
});

it('๋‹ด๋‹น ํ•™์ƒ์˜ ๊ธ€์„ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค', () => {
const post = subject('StudentPost', {
instructorId: 'inst-1',
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(true);
});

it('๊ธ€์„ ์ˆ˜์ •/์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
instructorId: 'inst-1',
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
} as Record<string, unknown>);
expect(ability.can(Action.Update, post)).toBe(false);
expect(ability.can(Action.Delete, post)).toBe(false);
});

it('ํƒ€ ๊ฐ•์‚ฌ ๋‹ด๋‹น ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
instructorId: 'other-inst',
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});
});

describe('ASSISTANT ์—ญํ• ', () => {
const ability = defineStudentPostAbility({
userType: UserType.ASSISTANT,
profileId: 'assi-1',
effectiveInstructorId: 'inst-1', // ์กฐ๊ต๊ฐ€ ๋‹ด๋‹นํ•˜๋Š” ๊ฐ•์‚ฌ ID
});

it('๋‹ด๋‹น ๊ฐ•์‚ฌ์˜ ๊ธ€์„ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค', () => {
const post = subject('StudentPost', {
instructorId: 'inst-1',
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(true);
});

it('ํƒ€ ๊ฐ•์‚ฌ ๋‹ด๋‹น ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
instructorId: 'other-inst',
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});
});

describe('PARENT ์—ญํ• ', () => {
const ability = defineStudentPostAbility({
userType: UserType.PARENT,
profileId: 'parent-1',
parentEnrollmentIds: ['enrollment-1'],
});

it('์ž๋…€ enrollment์˜ PARENT ๊ธ€์„ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.PARENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(true);
});

it('์ž๋…€ enrollment์˜ STUDENT ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'enrollment-1',
authorRole: AuthorRole.STUDENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});

it('ํƒ€์ธ ์ž๋…€ enrollment์˜ ๊ธ€์€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค', () => {
const post = subject('StudentPost', {
enrollmentId: 'other-enrollment',
authorRole: AuthorRole.PARENT,
instructorId: 'inst-1',
} as Record<string, unknown>);
expect(ability.can(Action.Read, post)).toBe(false);
});
});
});
72 changes: 72 additions & 0 deletions src/casl/student-post.ability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { AbilityBuilder } from '@casl/ability';
import { createPrismaAbility } from '@casl/prisma';
import { Action as A } from './actions.js';
import type { UserType } from '../constants/auth.constant.js';
import { UserType as UT } from '../constants/auth.constant.js';
import { AuthorRole } from '../constants/posts.constant.js';
import type { AppAbility } from './ability.types.js';

export interface AbilityContext {
userType: UserType;
profileId: string;
enrollmentIds?: string[];
effectiveInstructorId?: string;
parentEnrollmentIds?: string[];
}

export function defineStudentPostAbility(ctx: AbilityContext): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(createPrismaAbility);

switch (ctx.userType) {
case UT.STUDENT: {
const condition = {
enrollmentId: { in: ctx.enrollmentIds ?? [] },
authorRole: AuthorRole.STUDENT,
};
can(A.Create, 'StudentPost', condition);
can(A.Read, 'StudentPost', condition);
can(A.Update, 'StudentPost', condition);
can(A.Delete, 'StudentPost', condition);
can(A.UpdateStatus, 'StudentPost', condition);
can(A.List, 'StudentPost', condition);
break;
}

case UT.INSTRUCTOR: {
if (!ctx.effectiveInstructorId) {
// Retrun empty ability - no permissions granted
break;
}
const condition = { instructorId: ctx.effectiveInstructorId };
can(A.Read, 'StudentPost', condition);
can(A.List, 'StudentPost', condition);
break;
}

case UT.ASSISTANT: {
if (!ctx.effectiveInstructorId) {
break;
}
const condition = { instructorId: ctx.effectiveInstructorId };
can(A.Read, 'StudentPost', condition);
can(A.List, 'StudentPost', condition);
break;
}

case UT.PARENT: {
const condition = {
enrollmentId: { in: ctx.parentEnrollmentIds ?? [] },
authorRole: AuthorRole.PARENT,
};
can(A.Create, 'StudentPost', condition);
can(A.Read, 'StudentPost', condition);
can(A.Update, 'StudentPost', condition);
can(A.Delete, 'StudentPost', condition);
can(A.UpdateStatus, 'StudentPost', condition);
can(A.List, 'StudentPost', condition);
break;
}
}

return build();
}
8 changes: 8 additions & 0 deletions src/casl/subjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { StudentPost } from '../generated/prisma/client.js';
import type { Subjects as PrismaSubjects } from '@casl/prisma';

export type AppSubjects =
| PrismaSubjects<{
StudentPost: StudentPost;
}>
| 'all';
3 changes: 0 additions & 3 deletions src/config/container.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ import { AssignmentCategoryController } from '../controllers/assignment-categori

import { SchedulesService } from '../services/schedules.service.js';
import { SchedulesController } from '../controllers/schedules.controller.js';
import { UploadsController } from '../controllers/uploads.controller.js';

import { DashboardRepository } from '../repos/dashboard.repo.js';
import { DashboardService } from '../services/dashboard.service.js';
Expand Down Expand Up @@ -391,7 +390,6 @@ const instructorPostsController = new InstructorPostsController(
);
const studentPostsController = new StudentPostsController(studentPostsService);
const commentsController = new CommentsController(commentsService);
const uploadsController = new UploadsController(fileStorageService);
const dashboardController = new DashboardController(dashboardService);

// 4. Create Middlewares (Inject Services)
Expand Down Expand Up @@ -458,7 +456,6 @@ export const container = {
assignmentsController,
assignmentResultsController,
dashboardController,
uploadsController,
profileController: new ProfileController(profileService),
// Middlewares
requireAuth,
Expand Down
Loading