Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
81b0e55
refactor: implement CASL authorization for instructor posts and centr…
rklpoi5678 Mar 9, 2026
49ca636
fix: casl ability abac for student-posts
rklpoi5678 Mar 9, 2026
b59464b
refactor: filestorage mock for services.mock
rklpoi5678 Mar 9, 2026
4b74597
feat: instructor-posts ability
rklpoi5678 Mar 9, 2026
29236ea
refactor: use casl/prisma
rklpoi5678 Mar 9, 2026
3eba3ac
fix: instructor-posts multer miss
rklpoi5678 Mar 9, 2026
21f5ba9
feat: materials download for parent
rklpoi5678 Mar 9, 2026
61d5072
refactor: instructor-posts for casl bussiness test
rklpoi5678 Mar 9, 2026
7a27e3c
fix: clean up freshly uploaded attachments when the write fails
rklpoi5678 Mar 9, 2026
5ca00c0
fix: these assertions no longer verify target-role filtering
rklpoi5678 Mar 9, 2026
6da9720
fix: wrap CASL's ForbiddenError in ForbiddenException
rklpoi5678 Mar 9, 2026
9cde938
fix: create a targetRole-aware access predicate or add targetRole
rklpoi5678 Mar 9, 2026
3bcd4cc
Merge branch 'dev' of https://github.com/SSambee/ssambee-be into chor…
rklpoi5678 Mar 12, 2026
d87082c
fix: clean up partial batch uploads here, not only in callers
rklpoi5678 Mar 12, 2026
542513e
fix: add cleanup() to this mock surface
rklpoi5678 Mar 12, 2026
b3b6ea1
fix: don't clean up uploaded files when only URL signing fails
rklpoi5678 Mar 12, 2026
00da5d1
fix: the new default assistant mock skips the authorAssistantId path
rklpoi5678 Mar 12, 2026
a06dfb0
fix: comments service attachements bug
rklpoi5678 Mar 12, 2026
ffff1f7
feat: attachement for student update
rklpoi5678 Mar 12, 2026
5739811
feat: assistant history dialog attachement
rklpoi5678 Mar 12, 2026
954c4b1
fix: replace z.coerce.boolean() with explicit string parsing for the …
rklpoi5678 Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/casl/instructor-post.ability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
defineInstructorPostAbility,
InstructorPostAbilityContext,
} from './instructor-post.ability';
import { UserType } from '../constants/auth.constant';
import { PostScope, TargetRole } from '../constants/posts.constant';
import { Action } from './actions';
import { subject } from '@casl/ability';
import { InstructorPost } from '../generated/prisma/client';

type InstructorPostWithTargets = InstructorPost & {
targets: { enrollmentId: string }[];
};

describe('InstructorPost Ability', () => {
describe('INSTRUCTOR', () => {
it('μžμ‹ μ˜ κ²Œμ‹œκΈ€(Manage)에 λŒ€ν•΄ λͺ¨λ“  κΆŒν•œμ„ κ°€μ§„λ‹€', () => {
const context: InstructorPostAbilityContext = {
userType: UserType.INSTRUCTOR,
profileId: 'instructor-1',
effectiveInstructorId: 'instructor-1',
};
const ability = defineInstructorPostAbility(context);

const myPost = subject('InstructorPost', {
instructorId: 'instructor-1',
} as InstructorPost);
const otherPost = subject('InstructorPost', {
instructorId: 'instructor-2',
} as InstructorPost);

expect(ability.can(Action.Manage, myPost)).toBe(true);
expect(ability.can(Action.Manage, otherPost)).toBe(false);
});
});

describe('ASSISTANT', () => {
it('λ‹΄λ‹Ή κ°•μ‚¬μ˜ κ²Œμ‹œκΈ€μ— λŒ€ν•˜μ—¬ 읽기/생성/μˆ˜μ •/μ‚­μ œ λ“± 쑰건뢀 κΆŒν•œμ„ κ²€μ¦ν•œλ‹€', () => {
const context: InstructorPostAbilityContext = {
userType: UserType.ASSISTANT,
profileId: 'assistant-1',
effectiveInstructorId: 'instructor-1',
};
const ability = defineInstructorPostAbility(context);

const myPost = subject('InstructorPost', {
instructorId: 'instructor-1',
authorAssistantId: 'assistant-1',
} as InstructorPost);

const instructorPost = subject('InstructorPost', {
instructorId: 'instructor-1',
authorAssistantId: null,
} as InstructorPost);

const otherPost = subject('InstructorPost', {
instructorId: 'instructor-2',
} as InstructorPost);

// 본인이 λ‹΄λ‹Ήν•˜λŠ” κ°•μ‚¬μ˜ κ²Œμ‹œνŒμ—μ„œλŠ” Create, Read κ°€λŠ₯
expect(ability.can(Action.Read, instructorPost)).toBe(true);
expect(ability.can(Action.Create, instructorPost)).toBe(true);

// 본인이 μž‘μ„±ν•œ κ²Œμ‹œκΈ€μ— ν•œν•΄μ„œλ§Œ μˆ˜μ •/μ‚­μ œ κ°€λŠ₯
expect(ability.can(Action.Update, myPost)).toBe(true);
expect(ability.can(Action.Delete, myPost)).toBe(true);

// 강사가 μ“΄ κ²Œμ‹œκΈ€μ€ μˆ˜μ •/μ‚­μ œ λΆˆκ°€
expect(ability.can(Action.Update, instructorPost)).toBe(false);
expect(ability.can(Action.Delete, instructorPost)).toBe(false);

// 타 κ°•μ‚¬μ˜ κ²Œμ‹œνŒμ€ μ•„μ˜ˆ μ ‘κ·Ό λΆˆκ°€
expect(ability.can(Action.Read, otherPost)).toBe(false);
});
});

describe('STUDENT', () => {
const context: InstructorPostAbilityContext = {
userType: UserType.STUDENT,
profileId: 'student-1',
studentFields: {
instructorIds: ['instructor-1', 'instructor-2'],
lectureIds: ['lecture-1'],
enrollmentIds: ['enrollment-1'],
},
};

it('ALL λ˜λŠ” STUDENT νƒ€κ²Ÿμ΄λ©΄μ„œ, μˆ˜κ°• 쀑인 κ°•μ‚¬μ˜ GLOBAL 곡지λ₯Ό μ‘°νšŒν•  수 μžˆλ‹€', () => {
const ability = defineInstructorPostAbility(context);

const successPost1 = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.ALL,
instructorId: 'instructor-1',
} as InstructorPost);

const successPost2 = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.STUDENT,
instructorId: 'instructor-1',
} as InstructorPost);

const failPostRole = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.PARENT,
instructorId: 'instructor-1',
} as InstructorPost);

const failPostInstructor = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.ALL,
instructorId: 'instructor-other',
} as InstructorPost);

expect(ability.can(Action.Read, successPost1)).toBe(true);
expect(ability.can(Action.Read, successPost2)).toBe(true);
expect(ability.can(Action.Read, failPostRole)).toBe(false);
expect(ability.can(Action.Read, failPostInstructor)).toBe(false);
});

it('μˆ˜κ°• 쀑인 κ°•μ˜(LECTURE)의 곡지λ₯Ό μ‘°νšŒν•  수 μžˆλ‹€', () => {
const ability = defineInstructorPostAbility(context);

const successPost = subject('InstructorPost', {
scope: PostScope.LECTURE,
targetRole: TargetRole.ALL,
lectureId: 'lecture-1',
} as InstructorPost);

const failPostLecture = subject('InstructorPost', {
scope: PostScope.LECTURE,
targetRole: TargetRole.ALL,
lectureId: 'lecture-other',
} as InstructorPost);

expect(ability.can(Action.Read, successPost)).toBe(true);
expect(ability.can(Action.Read, failPostLecture)).toBe(false);
});

it('νƒ€κ²Ÿ(SELECTED) 곡지에 본인의 μˆ˜κ°•ID(enrollmentId)κ°€ ν¬ν•¨λ˜μ–΄ 있으면 μ‘°νšŒν•  수 μžˆλ‹€', () => {
const ability = defineInstructorPostAbility(context);

const successPost = subject('InstructorPost', {
scope: PostScope.SELECTED,
targetRole: TargetRole.ALL,
targets: [{ enrollmentId: 'enrollment-1' }],
} as unknown as InstructorPostWithTargets);

const failPost = subject('InstructorPost', {
scope: PostScope.SELECTED,
targetRole: TargetRole.ALL,
targets: [{ enrollmentId: 'enrollment-other' }],
} as unknown as InstructorPostWithTargets);

expect(ability.can(Action.Read, successPost)).toBe(true);
expect(ability.can(Action.Read, failPost)).toBe(false);
});
});

describe('PARENT', () => {
const context: InstructorPostAbilityContext = {
userType: UserType.PARENT,
profileId: 'parent-1',
parentFields: {
instructorIds: ['instructor-1'],
lectureIds: ['lecture-1'],
enrollmentIds: ['enrollment-1'],
},
};

it('ALL λ˜λŠ” PARENT νƒ€κ²Ÿμ΄λ©΄μ„œ, μžλ…€κ°€ μˆ˜κ°• 쀑인 κ°•μ‚¬μ˜ GLOBAL κ³΅μ§€λ§Œ μ‘°νšŒν•  수 μžˆλ‹€', () => {
const ability = defineInstructorPostAbility(context);

const successPost = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.PARENT,
instructorId: 'instructor-1',
} as InstructorPost);

const failPostRole = subject('InstructorPost', {
scope: PostScope.GLOBAL,
targetRole: TargetRole.STUDENT,
instructorId: 'instructor-1',
} as InstructorPost);

expect(ability.can(Action.Read, successPost)).toBe(true);
expect(ability.can(Action.Read, failPostRole)).toBe(false);
});

it('보호자(PARENT) νƒ€κ²Ÿ LECTURE 곡지에 μžλ…€κ°€ μ†ν•΄μžˆμœΌλ©΄ 쑰회 κ°€λŠ₯', () => {
const ability = defineInstructorPostAbility(context);

const successPost = subject('InstructorPost', {
scope: PostScope.LECTURE,
targetRole: TargetRole.PARENT,
lectureId: 'lecture-1',
} as InstructorPost);

expect(ability.can(Action.Read, successPost)).toBe(true);
});

it('보호자(PARENT) νƒ€κ²Ÿ SELECTED 곡지에 μžλ…€κ°€ μ§€μ •λ˜μ–΄μžˆμœΌλ©΄ 쑰회 κ°€λŠ₯', () => {
const ability = defineInstructorPostAbility(context);

const successPost = subject('InstructorPost', {
scope: PostScope.SELECTED,
targetRole: TargetRole.PARENT,
targets: [{ enrollmentId: 'enrollment-1' }],
} as unknown as InstructorPostWithTargets);

expect(ability.can(Action.Read, successPost)).toBe(true);
});
});
});
158 changes: 158 additions & 0 deletions src/casl/instructor-post.ability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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 { PostScope, TargetRole } from '../constants/posts.constant.js';
import type { AppAbility } from './ability.types.js';

export interface InstructorPostAbilityContext {
userType: UserType;
profileId: string;
effectiveInstructorId?: string;
studentFields?: {
lectureIds: string[];
instructorIds: string[];
enrollmentIds: string[];
};
parentFields?: {
lectureIds: string[];
instructorIds: string[];
enrollmentIds: string[];
};
}

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

switch (ctx.userType) {
case UT.INSTRUCTOR: {
if (ctx.effectiveInstructorId) {
can(A.Manage, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
});
}
break;
}

case UT.ASSISTANT: {
if (ctx.effectiveInstructorId) {
// μ†Œμ† κ°•μ‚¬μ˜ λͺ¨λ“  κ²Œμ‹œκΈ€ 쑰회 κ°€λŠ₯
can(A.Read, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
});
can(A.List, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
});

// μ†Œμ† κ°•μ‚¬μ˜ μ΄λ¦„μœΌλ‘œ κ²Œμ‹œκΈ€ μž‘μ„± κ°€λŠ₯
can(A.Create, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
});

// 본인이 μž‘μ„±ν•œ κ²Œμ‹œκΈ€λ§Œ μˆ˜μ •/μ‚­μ œ κ°€λŠ₯
can(A.Update, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
authorAssistantId: ctx.profileId,
});
can(A.Delete, 'InstructorPost', {
instructorId: ctx.effectiveInstructorId,
authorAssistantId: ctx.profileId,
});
}
break;
}

case UT.STUDENT: {
if (ctx.studentFields) {
const { lectureIds, instructorIds, enrollmentIds } = ctx.studentFields;
const targetRoles = [TargetRole.ALL, TargetRole.STUDENT];

// 1. GLOBAL: λ‚΄κ°€ μˆ˜κ°• 쀑인 κ°•μ‚¬μ˜ 전체 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.GLOBAL,
instructorId: { in: instructorIds },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.GLOBAL,
instructorId: { in: instructorIds },
});

// 2. LECTURE: λ‚΄κ°€ μˆ˜κ°• 쀑인 κ°•μ˜μ˜ 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.LECTURE,
lectureId: { in: lectureIds },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.LECTURE,
lectureId: { in: lectureIds },
});

// 3. SELECTED: λ‚΄κ°€ νƒ€κ²ŸμœΌλ‘œ μ§€μ •λœ 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.SELECTED,
targets: { some: { enrollmentId: { in: enrollmentIds } } },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.SELECTED,
targets: { some: { enrollmentId: { in: enrollmentIds } } },
});
}
break;
}

case UT.PARENT: {
if (ctx.parentFields) {
const { lectureIds, instructorIds, enrollmentIds } = ctx.parentFields;
const targetRoles = [TargetRole.ALL, TargetRole.PARENT];

// 1. GLOBAL: μžλ…€κ°€ μˆ˜κ°• 쀑인 κ°•μ‚¬μ˜ 전체 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.GLOBAL,
instructorId: { in: instructorIds },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.GLOBAL,
instructorId: { in: instructorIds },
});

// 2. LECTURE: μžλ…€κ°€ μˆ˜κ°• 쀑인 κ°•μ˜μ˜ 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.LECTURE,
lectureId: { in: lectureIds },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.LECTURE,
lectureId: { in: lectureIds },
});

// 3. SELECTED: μžλ…€κ°€ νƒ€κ²ŸμœΌλ‘œ μ§€μ •λœ 곡지
can(A.Read, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.SELECTED,
targets: { some: { enrollmentId: { in: enrollmentIds } } },
});
can(A.List, 'InstructorPost', {
targetRole: { in: targetRoles },
scope: PostScope.SELECTED,
targets: { some: { enrollmentId: { in: enrollmentIds } } },
});
}
break;
}
}

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

export type AppSubjects =
| PrismaSubjects<{
StudentPost: StudentPost;
InstructorPost: InstructorPost;
}>
| 'all';
Loading
Loading