Skip to content

Commit 6f8810e

Browse files
authored
Merge pull request #164 from SSambee/chore/casl
chore/casl orphan s3, upload etc
2 parents 8ea5fc3 + 82cd98c commit 6f8810e

19 files changed

Lines changed: 616 additions & 332 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"@aws-sdk/client-ssm": "^3.988.0",
3838
"@aws-sdk/cloudfront-signer": "^3.988.0",
3939
"@aws-sdk/s3-request-presigner": "^3.984.0",
40+
"@casl/ability": "^6.8.0",
41+
"@casl/prisma": "^1.6.1",
4042
"@prisma/adapter-pg": "^7.2.0",
4143
"@prisma/client": "^7.2.0",
4244
"@sentry/node": "^10.38.0",

pnpm-lock.yaml

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/casl/ability.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { PureAbility } from '@casl/ability';
2+
import type { PrismaQuery } from '@casl/prisma';
3+
import type { Action } from './actions.js';
4+
import type { AppSubjects } from './subjects.js';
5+
6+
export type AppAbility = PureAbility<[Action, AppSubjects], PrismaQuery>;

src/casl/actions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const Action = {
2+
Create: 'create',
3+
Read: 'read',
4+
Update: 'update',
5+
Delete: 'delete',
6+
UpdateStatus: 'updateStatus',
7+
List: 'list',
8+
Manage: 'manage',
9+
} as const;
10+
11+
export type Action = (typeof Action)[keyof typeof Action];
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { subject } from '@casl/ability';
2+
import { defineStudentPostAbility } from './student-post.ability.js';
3+
import { Action } from './actions.js';
4+
import { UserType } from '../constants/auth.constant.js';
5+
import { AuthorRole } from '../constants/posts.constant.js';
6+
7+
describe('StudentPost Ability', () => {
8+
describe('STUDENT 역할', () => {
9+
const ability = defineStudentPostAbility({
10+
userType: UserType.STUDENT,
11+
profileId: 'student-1',
12+
enrollmentIds: ['enrollment-1', 'enrollment-2'],
13+
});
14+
15+
it('자기 enrollment의 STUDENT 글을 읽을 수 있다', () => {
16+
const post = subject('StudentPost', {
17+
enrollmentId: 'enrollment-1',
18+
authorRole: AuthorRole.STUDENT,
19+
instructorId: 'inst-1',
20+
} as Record<string, unknown>);
21+
expect(ability.can(Action.Read, post)).toBe(true);
22+
});
23+
24+
it('타인 enrollment의 글은 읽을 수 없다', () => {
25+
const post = subject('StudentPost', {
26+
enrollmentId: 'other-enrollment',
27+
authorRole: AuthorRole.STUDENT,
28+
instructorId: 'inst-1',
29+
} as Record<string, unknown>);
30+
expect(ability.can(Action.Read, post)).toBe(false);
31+
});
32+
33+
it('PARENT가 작성한 글은 읽을 수 없다', () => {
34+
const post = subject('StudentPost', {
35+
enrollmentId: 'enrollment-1',
36+
authorRole: AuthorRole.PARENT,
37+
instructorId: 'inst-1',
38+
} as Record<string, unknown>);
39+
expect(ability.can(Action.Read, post)).toBe(false);
40+
});
41+
42+
it('글을 삭제할 수 있다 (자기 enrollment)', () => {
43+
const post = subject('StudentPost', {
44+
enrollmentId: 'enrollment-1',
45+
authorRole: AuthorRole.STUDENT,
46+
instructorId: 'inst-1',
47+
} as Record<string, unknown>);
48+
expect(ability.can(Action.Delete, post)).toBe(true);
49+
});
50+
});
51+
52+
describe('INSTRUCTOR 역할', () => {
53+
const ability = defineStudentPostAbility({
54+
userType: UserType.INSTRUCTOR,
55+
profileId: 'inst-1',
56+
effectiveInstructorId: 'inst-1',
57+
});
58+
59+
it('담당 학생의 글을 읽을 수 있다', () => {
60+
const post = subject('StudentPost', {
61+
instructorId: 'inst-1',
62+
enrollmentId: 'enrollment-1',
63+
authorRole: AuthorRole.STUDENT,
64+
} as Record<string, unknown>);
65+
expect(ability.can(Action.Read, post)).toBe(true);
66+
});
67+
68+
it('글을 수정/삭제할 수 없다', () => {
69+
const post = subject('StudentPost', {
70+
instructorId: 'inst-1',
71+
enrollmentId: 'enrollment-1',
72+
authorRole: AuthorRole.STUDENT,
73+
} as Record<string, unknown>);
74+
expect(ability.can(Action.Update, post)).toBe(false);
75+
expect(ability.can(Action.Delete, post)).toBe(false);
76+
});
77+
78+
it('타 강사 담당 글은 읽을 수 없다', () => {
79+
const post = subject('StudentPost', {
80+
instructorId: 'other-inst',
81+
enrollmentId: 'enrollment-1',
82+
authorRole: AuthorRole.STUDENT,
83+
} as Record<string, unknown>);
84+
expect(ability.can(Action.Read, post)).toBe(false);
85+
});
86+
});
87+
88+
describe('ASSISTANT 역할', () => {
89+
const ability = defineStudentPostAbility({
90+
userType: UserType.ASSISTANT,
91+
profileId: 'assi-1',
92+
effectiveInstructorId: 'inst-1', // 조교가 담당하는 강사 ID
93+
});
94+
95+
it('담당 강사의 글을 읽을 수 있다', () => {
96+
const post = subject('StudentPost', {
97+
instructorId: 'inst-1',
98+
enrollmentId: 'enrollment-1',
99+
authorRole: AuthorRole.STUDENT,
100+
} as Record<string, unknown>);
101+
expect(ability.can(Action.Read, post)).toBe(true);
102+
});
103+
104+
it('타 강사 담당 글은 읽을 수 없다', () => {
105+
const post = subject('StudentPost', {
106+
instructorId: 'other-inst',
107+
enrollmentId: 'enrollment-1',
108+
authorRole: AuthorRole.STUDENT,
109+
} as Record<string, unknown>);
110+
expect(ability.can(Action.Read, post)).toBe(false);
111+
});
112+
});
113+
114+
describe('PARENT 역할', () => {
115+
const ability = defineStudentPostAbility({
116+
userType: UserType.PARENT,
117+
profileId: 'parent-1',
118+
parentEnrollmentIds: ['enrollment-1'],
119+
});
120+
121+
it('자녀 enrollment의 PARENT 글을 읽을 수 있다', () => {
122+
const post = subject('StudentPost', {
123+
enrollmentId: 'enrollment-1',
124+
authorRole: AuthorRole.PARENT,
125+
instructorId: 'inst-1',
126+
} as Record<string, unknown>);
127+
expect(ability.can(Action.Read, post)).toBe(true);
128+
});
129+
130+
it('자녀 enrollment의 STUDENT 글은 읽을 수 없다', () => {
131+
const post = subject('StudentPost', {
132+
enrollmentId: 'enrollment-1',
133+
authorRole: AuthorRole.STUDENT,
134+
instructorId: 'inst-1',
135+
} as Record<string, unknown>);
136+
expect(ability.can(Action.Read, post)).toBe(false);
137+
});
138+
139+
it('타인 자녀 enrollment의 글은 읽을 수 없다', () => {
140+
const post = subject('StudentPost', {
141+
enrollmentId: 'other-enrollment',
142+
authorRole: AuthorRole.PARENT,
143+
instructorId: 'inst-1',
144+
} as Record<string, unknown>);
145+
expect(ability.can(Action.Read, post)).toBe(false);
146+
});
147+
});
148+
});

src/casl/student-post.ability.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { AbilityBuilder } from '@casl/ability';
2+
import { createPrismaAbility } from '@casl/prisma';
3+
import { Action as A } from './actions.js';
4+
import type { UserType } from '../constants/auth.constant.js';
5+
import { UserType as UT } from '../constants/auth.constant.js';
6+
import { AuthorRole } from '../constants/posts.constant.js';
7+
import type { AppAbility } from './ability.types.js';
8+
9+
export interface AbilityContext {
10+
userType: UserType;
11+
profileId: string;
12+
enrollmentIds?: string[];
13+
effectiveInstructorId?: string;
14+
parentEnrollmentIds?: string[];
15+
}
16+
17+
export function defineStudentPostAbility(ctx: AbilityContext): AppAbility {
18+
const { can, build } = new AbilityBuilder<AppAbility>(createPrismaAbility);
19+
20+
switch (ctx.userType) {
21+
case UT.STUDENT: {
22+
const condition = {
23+
enrollmentId: { in: ctx.enrollmentIds ?? [] },
24+
authorRole: AuthorRole.STUDENT,
25+
};
26+
can(A.Create, 'StudentPost', condition);
27+
can(A.Read, 'StudentPost', condition);
28+
can(A.Update, 'StudentPost', condition);
29+
can(A.Delete, 'StudentPost', condition);
30+
can(A.UpdateStatus, 'StudentPost', condition);
31+
can(A.List, 'StudentPost', condition);
32+
break;
33+
}
34+
35+
case UT.INSTRUCTOR: {
36+
if (!ctx.effectiveInstructorId) {
37+
// Retrun empty ability - no permissions granted
38+
break;
39+
}
40+
const condition = { instructorId: ctx.effectiveInstructorId };
41+
can(A.Read, 'StudentPost', condition);
42+
can(A.List, 'StudentPost', condition);
43+
break;
44+
}
45+
46+
case UT.ASSISTANT: {
47+
if (!ctx.effectiveInstructorId) {
48+
break;
49+
}
50+
const condition = { instructorId: ctx.effectiveInstructorId };
51+
can(A.Read, 'StudentPost', condition);
52+
can(A.List, 'StudentPost', condition);
53+
break;
54+
}
55+
56+
case UT.PARENT: {
57+
const condition = {
58+
enrollmentId: { in: ctx.parentEnrollmentIds ?? [] },
59+
authorRole: AuthorRole.PARENT,
60+
};
61+
can(A.Create, 'StudentPost', condition);
62+
can(A.Read, 'StudentPost', condition);
63+
can(A.Update, 'StudentPost', condition);
64+
can(A.Delete, 'StudentPost', condition);
65+
can(A.UpdateStatus, 'StudentPost', condition);
66+
can(A.List, 'StudentPost', condition);
67+
break;
68+
}
69+
}
70+
71+
return build();
72+
}

src/casl/subjects.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { StudentPost } from '../generated/prisma/client.js';
2+
import type { Subjects as PrismaSubjects } from '@casl/prisma';
3+
4+
export type AppSubjects =
5+
| PrismaSubjects<{
6+
StudentPost: StudentPost;
7+
}>
8+
| 'all';

src/config/container.config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ import { AssignmentCategoryController } from '../controllers/assignment-categori
9292

9393
import { SchedulesService } from '../services/schedules.service.js';
9494
import { SchedulesController } from '../controllers/schedules.controller.js';
95-
import { UploadsController } from '../controllers/uploads.controller.js';
9695

9796
import { DashboardRepository } from '../repos/dashboard.repo.js';
9897
import { DashboardService } from '../services/dashboard.service.js';
@@ -391,7 +390,6 @@ const instructorPostsController = new InstructorPostsController(
391390
);
392391
const studentPostsController = new StudentPostsController(studentPostsService);
393392
const commentsController = new CommentsController(commentsService);
394-
const uploadsController = new UploadsController(fileStorageService);
395393
const dashboardController = new DashboardController(dashboardService);
396394

397395
// 4. Create Middlewares (Inject Services)
@@ -458,7 +456,6 @@ export const container = {
458456
assignmentsController,
459457
assignmentResultsController,
460458
dashboardController,
461-
uploadsController,
462459
profileController: new ProfileController(profileService),
463460
// Middlewares
464461
requireAuth,

0 commit comments

Comments
 (0)