NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
운영자가 본인 담당 클래스 1개만 떠야 하는데 모든 클래스가 떴다. 목록 API에 operatorId 필터를 깔고 끝낸 줄 알았는데, 직접 URL로 미담당 클래스 ID를 두드리니 상세·수정·승인 5개 엔드포인트가 그대로 200을 돌려줬다. 원인은 JWT payload.sub(User ID)와 Operator 테이블 id(Operator ID)의 분리 + validateClassAccess 헬퍼 부재 둘이었다. 라운드 한 번에 BE → QA → 추가 BE → QA 재검증으로 닫은 NestJS ForbiddenException + Prisma classOperator.findFirst 패턴을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상: 운영자(
TENANT_OPERATOR)가 클래스 목록은 본인 담당 1개만 떴는데, 미담당 클래스 ID로/tenants/me/classes/30을 직접 두드리니 HTTP 200으로 상세가 뚫렸다.- 표면 원인 1 (JWT): 컨트롤러가
payload.userId를 읽는데, 실제 JWT 표준 페이로드는sub필드에 사용자 ID를 담는다.userId는 **항상undefined**여서 필터가 사실상 비활성화됐다.- 표면 원인 2 (ID 분리):
payload.sub는 User ID(CUID),classOperator.operatorId는 Operator ID(number). 두 ID는 1:1이지만 같은 값이 아니라서, JWT를 고쳐도 한 번 더operator.findFirst({ where: { userId } })로 변환해야 한다.- 진짜 원인: 목록 API에는
operatorId필터가 있지만 상세·수정·승인 5개 엔드포인트는 공통 권한 헬퍼 없이 각자 컨트롤러에서 처리해 직접 URL 우회가 한 줄도 안 막혔다.- 해결:
validateClassAccess(classId, payload)헬퍼 1개를 도메인 서비스에 두고getClassById/getClassMembers/updateClass/getLevelApprovals/approveLevelChange다섯 메서드 첫 줄에서 호출. 미담당이면ForbiddenException.- 교훈: 목록을 막았다고 상세가 막힌 것이 아니다. Refine처럼 목록·상세·수정·일괄 처리가 같은 모델을 독립 엔드포인트로 노출하면, 권한은 모델 단위가 아니라 엔드포인트 단위로 다시 깔아야 한다. 헬퍼 한 곳·호출 다섯 곳이 표준.
🌱 왜 이 버그가 라운드 후반에 잡혔나
이전 편에서 Problem 모델의 콘텐츠 종속을 끊고 단위 테스트 38개를 한 머지에 같이 올렸다. 그 머지가 dev에 들어간 뒤 운영자 권한 점검이 후속 작업으로 잡혔다.
운영자 계정은 본인 담당 클래스만 봐야 한다. 한 고객사(Tenant) 안에서 운영자가 여러 명이고, 각자 1~3개 클래스를 맡는다. “내 클래스만 보여줘”가 안 되면 다른 운영자가 담당하는 클래스의 회원 명단·레벨 변경 요청·정책 변경이 노출된다. 점검 우선순위 Critical.
라운드는 두 단계로 묶여 있었다. 1단계는 클래스 목록(GET /tenants/me/classes), 2단계는 클래스 상세(GET /tenants/me/classes/:id)와 그 뒤 회원 명단·수정·레벨 승인 4개. 1단계는 컨트롤러 한 줄 추가로 끝났고 QA 통과. 그런데 QA가 2단계 점검 케이스로 “미담당 클래스 URL 직접 입력”을 돌리자 200이 떨어졌다.
📌 핵심: RBAC을 깔 때 흔히 빠지는 함정은 “목록 API에 필터를 추가했으니 권한이 닫혔다”고 믿는 것이다. Refine 같은 어드민 프레임워크는 목록·상세·수정·일괄 처리를 각각 독립 엔드포인트로 호출한다. 한 화면이 가려져도 그 화면이 부르는 다섯 엔드포인트 중 하나가 안 막혀 있으면 권한 우회가 그 한 곳에서 터진다.
🔥 증상 — 목록은 1개, 상세는 200
운영자 계정으로 로그인한 뒤 클래스 목록과 미담당 클래스 상세를 차례로 두드렸다.
# 운영자 토큰 (role: TENANT_OPERATOR, sub: ckopr_xxx, tenantId: 7)
# 1) 클래스 목록 — 본인 담당 1개만 떠야 함
curl -H "Authorization: Bearer $OP_TOKEN" \
https://api.example.com/tenants/me/classes
# → { "items": [{ "id": 31, "name": "QA테스트반" }], "total": 1 } ✅
# 2) 미담당 클래스 상세 — 403이 떨어져야 함
curl -H "Authorization: Bearer $OP_TOKEN" \
https://api.example.com/tenants/me/classes/30
# → { "id": 30, "name": "초등 아라온반", "operatorIds": [12, 14], ... } ❌ 200
목록은 깨끗했다. 한 운영자가 한 클래스만 맡고 있어서 1개가 떨어졌다. 그런데 상세는 ID 30(다른 운영자가 맡는 클래스)을 입력하자 그대로 200이 떨어졌다. 화면 UI에서는 클래스 카드가 안 보이지만, 개발자 도구나 URL 직접 입력으로는 그대로 노출된다.
같은 패턴을 다른 네 엔드포인트에서도 확인했다.
| 엔드포인트 | 운영자 → 미담당 클래스 | 기대 | 실제 |
|---|---|---|---|
GET /tenants/me/classes | 목록 | 1개만 | ✅ 1개 |
GET /tenants/me/classes/30 | 상세 | 403 | ❌ 200 + 정보 노출 |
GET /tenants/me/classes/30/members | 회원 명단 | 403 | ❌ 200 + 회원 목록 |
PATCH /tenants/me/classes/30 | 클래스 정보 수정 | 403 | ❌ 200 + 수정 반영 |
GET /tenants/me/classes/30/level-approvals | 레벨 변경 승인 큐 | 403 | ❌ 200 + 큐 노출 |
POST /tenants/me/classes/30/level-approvals/abc | 레벨 변경 승인 | 403 | ❌ 200 + 승인 반영 |
다섯 엔드포인트가 같은 패턴으로 뚫렸다. 한 곳 빠진 게 아니라 상세 계열 전부가 권한 체크 없이 노출되고 있었다.

🔍 탐색 — 잘못된 가설들
원인 후보가 셋이었다. 시간순으로 하나씩 깠다.
가설 1: JWT 자체가 잘못 발급됐다?
처음에는 JWT의 role claim이 OWNER로 잘못 발급된 줄 알았다. OWNER는 모든 클래스에 접근 가능하므로 그 분기를 타면 200이 자연스럽다.
# JWT 디코드 — jwt.io의 디코더로 페이로드 확인
{
"sub": "ckopr_8a3f...",
"tenantId": 7,
"role": "TENANT_OPERATOR", // ← OWNER 아님
"iat": 1737636800,
"exp": 1737640400
}
role은 정확히 TENANT_OPERATOR다. 토큰 자체 문제가 아니다.
가설 2: Guard가 안 붙어 있다?
JwtAuthGuard나 TenantGuard가 빠져서 무인증으로 통과한 줄 알았다. 컨트롤러 데코레이터를 확인했다.
// academy-class.controller.ts
@Controller("tenants/me/classes")
@UseGuards(JwtAuthGuard, TenantGuard) // ← 둘 다 붙어 있음
export class TenantClassController { ... }
Guard는 둘 다 정상 적용 중. 토큰 없이 두드리면 401이 떨어진다. Guard가 문제가 아니다.
가설 3: 목록 필터가 안 먹은 게 아니다?
목록은 잘 막혔으니 가설 3은 반증된 가설로 빠르게 닫혔다. 컨트롤러를 열어보니 목록만 명시적으로 운영자 ID를 받아 필터링하고 있었다.
// academy-class.controller.ts (line 93-95)
@Get()
async getClasses(...) {
const operatorId = payload.role === "TENANT_OPERATOR" ? payload.userId : undefined;
// ^^^^^^^^^^^^^^^^
// (1) 필드명 그리고 (2) ID 종류 둘 다 함정
return this.classService.getClasses(payload.tenantId, query, operatorId);
}
여기서 두 가지 의문이 같이 떴다. payload.userId는 실제로 undefined인지, 그리고 그 값이 classOperator.operatorId와 같은 값인지. JWT 표준 페이로드는 sub에 사용자 ID를 담는다는 게 머리에 떠올랐다.
sub는 RFC 7519가 정의한 등록된 클레임(registered claim)이다. JWT를 발급하는 라이브러리(@nestjs/jwt 포함)는 보통 sub에 식별자를 담는다. 컨트롤러가 payload.userId를 읽으면 사실상 undefined를 매번 받는다.
🎯 진짜 원인 — JWT 필드 불일치 + Operator ID 분리, 그리고 헬퍼 부재

근본 원인은 두 겹이었다.
원인 A — JWT 필드 불일치 (payload.userId → payload.sub)
NestJS의 인증 모듈은 토큰을 발급할 때 페이로드 객체의 키를 그대로 직렬화한다. 우리 토큰 발급기는 표준대로 sub에 사용자 ID(CUID)를 담았다.
// auth.service.ts — 발급 시
const token = await this.jwtService.signAsync({
sub: user.id, // ← User ID (CUID, 예: "ckopr_8a3f...")
tenantId: tenant.id,
role: user.role,
});
검증 후 컨트롤러로 들어올 때는 request.user가 이 payload 그대로다. 그런데 컨트롤러는 다른 키를 읽고 있었다.
// 컨트롤러가 읽던 인터페이스 (잘못됨)
interface JwtPayload {
tenantId: number;
userId: string; // ← 실제 토큰엔 이 키가 없다. 항상 undefined
role: string;
}
payload.userId는 매 요청에서 undefined였다. 그러니 목록 필터의 operatorId 변수도 undefined로 들어갔고, 서비스 단의 where는 필터 조건이 빠진 채로 돌았다. 목록이 1개로 떨어진 건 이 운영자의 담당 클래스가 마침 1개였기 때문이지, 필터가 동작해서가 아니었다. 운영자가 두 개 이상 담당하는 케이스로 시드를 바꿔봤다.
# 운영자에게 클래스 2개 매핑한 시드로 재현
curl -H "Authorization: Bearer $OP_TOKEN" \
https://api.example.com/tenants/me/classes
# → { "items": [{...3개...}], "total": 3 } ❌
# 본인 2개 + 다른 운영자 1개까지 노출됨
증상이 분명해졌다. 목록도 사실 안 막혀 있었다. 한 운영자가 한 클래스만 맡았던 시드 데이터 덕에 우연히 정답처럼 보였을 뿐이다.
원인 B — payload.sub ≠ operator.id
userId를 sub로 바꾼다고 끝이 아니었다. 다음 한 겹이 더 있었다.
payload.sub는 User 테이블의 ID(CUID 문자열)다. 하지만 classOperator 매핑 테이블이 가진 외래키는 Operator 테이블의 ID(number)다. User와 Operator는 1:1이지만 별도의 ID 컬럼을 가진다.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
role Role
operator Operator?
// ...
}
model Operator {
id Int @id @default(autoincrement())
userId String @unique
user User @relation(fields: [userId], references: [id])
classes ClassOperator[]
// ...
}
model ClassOperator {
classId Int
operatorId Int // ← Operator.id (Int), not User.id (String)
unassignedAt DateTime?
@@id([classId, operatorId])
}
payload.sub("ckopr_8a3f...")를 그대로 where: { operatorId }에 넘기면 타입부터 안 맞고, 맞춰서 캐스팅해도 매핑이 0건이라 항상 미담당으로 잡힌다. 반드시 한 번 더 User → Operator 변환을 거쳐야 한다.
// 한 번 더 변환이 필요
const operator = await this.prisma.operator.findFirst({
where: { userId: payload.sub },
});
const operatorId = operator?.id; // ← 이 값이 ClassOperator.operatorId와 같은 타입·같은 값
원인 C — 상세 계열은 공통 헬퍼가 없었다
여기까지가 목록 필터를 진짜로 동작시키기 위한 두 겹이다. 그런데 더 큰 문제는 따로 있었다. 목록은 그래도 한 줄(operatorId 인자 전달)이라도 깔려 있었지만, 상세·수정·승인 5개 엔드포인트는 권한 체크가 한 줄도 없었다.
// academy-class.controller.ts — 수정 전
@Get(":id")
async getClassById(@Param("id") id: number, @Req() req: AuthedRequest) {
// 권한 체크 없음 — tenantId 안의 클래스라면 누구든 다 본다
return this.classService.getClassById(id, req.user.tenantId);
}
@Get(":id/members")
async getClassMembers(@Param("id") id: number, @Req() req: AuthedRequest) {
// 권한 체크 없음
return this.memberService.getMembersByClass(id, req.user.tenantId);
}
// ... updateClass, getLevelApprovals, approveLevelChange 모두 동일 패턴
다섯 메서드가 tenantId(고객사 격리)는 막고 있지만, 한 고객사 안에서 운영자 간의 클래스 분리는 한 줄도 안 들어갔다. 한 곳을 빼먹은 게 아니라 다섯 곳에 같은 코드를 다 안 깐 상태였다. 이래서 컨트롤러 권한은 메서드별로 깔지 말고 공통 헬퍼로 끌어올려야 한다.
🛠️ 해결 — 헬퍼 1개 + 호출 5곳, 한 머지에
수정은 한 머지에 묶었다. JWT 인터페이스 바로잡기, Operator 변환 캡슐화, 다섯 엔드포인트에 헬퍼 호출 깔기.
1) JWT 인터페이스를 sub 표준에 맞춤
타입을 표준에 맞게 고치고, 한 곳에서 정의해서 다섯 컨트롤러가 같은 타입을 임포트하도록 했다.
// auth/types/jwt-payload.ts
export interface JwtPayload {
sub: string; // User ID (CUID) — RFC 7519 `sub` claim
tenantId: number;
role: "TENANT_OWNER" | "TENANT_OPERATOR" | "ADMIN";
}
기존 payload.userId를 읽던 코드를 ts-morph 없이 rg로 한 번에 잡았다.
rg "payload\.userId" apps/api/src
# academy-class.controller.ts: 1
# academy-student.controller.ts: 3
# class-policy.controller.ts: 1
# 총 5건 — 모두 payload.sub로 치환
2) validateClassAccess 헬퍼를 도메인 서비스에
권한 검증 로직 하나를 도메인 서비스로 끌어올렸다. 컨트롤러는 이 헬퍼를 호출만 한다.
// domain/services/class-access.service.ts
@Injectable()
export class ClassAccessService {
constructor(private prisma: PrismaService) {}
/**
* 호출자가 해당 클래스에 접근할 권한이 있는지 검증한다.
* - TENANT_OWNER: 같은 tenantId의 모든 클래스 접근 가능
* - TENANT_OPERATOR: Operator 테이블의 자기 행을 거쳐 ClassOperator로 담당 클래스만 접근
*
* 권한 없으면 `ForbiddenException` 즉시 throw.
*/
async validateClassAccess(classId: number, payload: JwtPayload): Promise<void> {
// OWNER는 같은 tenant 안의 모든 클래스 접근 가능
if (payload.role === "TENANT_OWNER") {
const cls = await this.prisma.class.findFirst({
where: { id: classId, tenantId: payload.tenantId },
select: { id: true },
});
if (!cls) throw new ForbiddenException("이 클래스에 대한 접근 권한이 없습니다");
return;
}
// OPERATOR는 User ID → Operator ID 변환 후 ClassOperator 매핑 확인
const operator = await this.prisma.operator.findFirst({
where: { userId: payload.sub, tenantId: payload.tenantId },
select: { id: true },
});
if (!operator) throw new ForbiddenException("운영자 정보를 찾을 수 없습니다");
const assignment = await this.prisma.classOperator.findFirst({
where: {
classId,
operatorId: operator.id,
unassignedAt: null, // 해지 이력은 제외
},
select: { classId: true },
});
if (!assignment) {
throw new ForbiddenException("이 클래스에 대한 접근 권한이 없습니다");
}
}
}
핵심은 세 줄이다.
findFirst({ where: { userId: payload.sub } })— User ID를 Operator ID로 변환.findFirst({ where: { classId, operatorId, unassignedAt: null } })— 매핑 존재 + 해지 안 됨 확인.- 둘 중 하나라도 비면
ForbiddenException. NestJS가 자동으로HTTP 403으로 직렬화한다.
unassignedAt: null 조건은 함정이라 따로 짚어둘 필요가 있다. ClassOperator에 해지 일자(unassignedAt)가 기록된 행을 안 거르면, 과거 담당이었던 클래스도 영구히 접근 가능해진다. 매핑 테이블에 소프트 삭제를 둘 때 흔히 새는 함정이다.
⚠️ 주의: 권한 헬퍼는 **외부에서 보이는 결과(throw)**가 한 줄이지만, 내부 쿼리는 두 번 돈다(
operator.findFirst+classOperator.findFirst). 핫 경로에 깔리면 DB 라운드트립이 늘어난다.select로 필요한 컬럼만 가져오고, 운영자별 매핑이 자주 바뀌지 않는다면 요청 스코프 캐싱(NestJS @Injectable({ scope: Scope.REQUEST })+ 메모이즈)으로 한 요청 안에서는 1회만 돌게 한다.
3) 다섯 엔드포인트 첫 줄에 헬퍼 호출
컨트롤러는 헬퍼만 호출하고 자기 로직을 이어 간다.
// academy-class.controller.ts — 수정 후
@Get(":id")
async getClassById(@Param("id") id: number, @Req() req: AuthedRequest) {
await this.classAccess.validateClassAccess(id, req.user);
return this.classService.getClassById(id, req.user.tenantId);
}
@Get(":id/members")
async getClassMembers(@Param("id") id: number, @Req() req: AuthedRequest) {
await this.classAccess.validateClassAccess(id, req.user);
return this.memberService.getMembersByClass(id, req.user.tenantId);
}
@Patch(":id")
async updateClass(@Param("id") id: number, @Body() dto: UpdateClassDto, @Req() req: AuthedRequest) {
await this.classAccess.validateClassAccess(id, req.user);
return this.classService.updateClass(id, dto, req.user.tenantId);
}
@Get(":id/level-approvals")
async getLevelApprovals(@Param("id") id: number, @Req() req: AuthedRequest) {
await this.classAccess.validateClassAccess(id, req.user);
return this.approvalService.getQueue(id);
}
@Post(":id/level-approvals/:memberId")
async approveLevelChange(
@Param("id") id: number,
@Param("memberId") memberId: string,
@Body() dto: ApproveDto,
@Req() req: AuthedRequest,
) {
await this.classAccess.validateClassAccess(id, req.user);
return this.approvalService.approve(id, memberId, dto);
}
다섯 메서드 모두 첫 줄에 동일한 호출. @Param 이름이 다르거나 인자가 추가돼도 위치만 보면 권한 체크가 있는지 한눈에 보인다. 코드 리뷰가 가능해진 게 헬퍼의 핵심 이득이다.
4) 목록 필터도 같이 정리
목록 쪽도 한 번 더 손봤다. payload.userId를 payload.sub로 바꾸고, Operator 변환 한 번 거치고, 서비스 호출. 같은 머지에 묶었다.
@Get()
async getClasses(@Query() query: ListClassesQuery, @Req() req: AuthedRequest) {
let operatorId: number | undefined;
if (req.user.role === "TENANT_OPERATOR") {
const op = await this.prisma.operator.findFirst({
where: { userId: req.user.sub, tenantId: req.user.tenantId },
select: { id: true },
});
operatorId = op?.id;
}
return this.classService.getClasses(req.user.tenantId, query, operatorId);
}
5) Swagger 응답에 403 추가
OpenAPI 스펙에 ApiResponse({ status: 403 })를 명시해서, Refine 쪽 FE가 응답 코드를 정상적으로 처리하도록 했다. 같은 작업으로 403 모달·토스트가 자동 생성된다.
@Get(":id")
@ApiOperation({ summary: "클래스 상세" })
@ApiResponse({ status: 200, type: ClassDetailResponse })
@ApiResponse({ status: 403, description: "이 클래스에 대한 접근 권한이 없습니다" })
async getClassById(...) { ... }
✅ 검증 — TC1·TC2 통과, TC3 재검증 PASS
수정 후 QA 재검증을 3개 케이스로 돌렸다.
| TC | 시나리오 | 기대 | 실제 |
|---|---|---|---|
| TC1 | OWNER → 클래스 목록 | 같은 tenant의 모든 클래스 | ✅ 2개 표시 |
| TC2 | OPERATOR → 클래스 목록 | 본인 담당 클래스만 | ✅ 1개 표시 |
| TC3 | OPERATOR → 미담당 클래스(/classes/30) 직접 접근 | 403 Forbidden | ✅ 403 — 이 클래스에 대한 접근 권한이 없습니다 |
같은 운영자 토큰으로 다섯 엔드포인트를 한 번씩 더 두드려서 모두 403이 떨어지는지 확인했다.
for path in "30" "30/members" "30/level-approvals"; do
status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $OP_TOKEN" \
"https://api.example.com/tenants/me/classes/$path")
echo "$path → $status"
done
# 30 → 403
# 30/members → 403
# 30/level-approvals → 403
PATCH·POST도 마찬가지로 403. 화면에서는 Refine dataProvider가 응답 코드를 잡아 “권한이 없습니다” 알림으로 떨어졌다.
라운드 한 번에 1단계(목록) + 2단계(상세·수정·승인 5개)를 같이 마감했다. 다음 라운드는 권한이 아니라 다른 트랙으로 넘어갔다.
🛡️ 예방 — 같은 패턴이 다시 안 새도록
같은 함정이 다른 모델에서 다시 안 새도록 체크리스트 네 줄.
JWT 표준 claim을 인터페이스 한 곳에서 정의
sub/iat/exp는 RFC 7519의 등록된 클레임이다. 컨트롤러마다 인터페이스를 다시 선언하는 대신, auth/types/jwt-payload.ts 같은 단일 파일에 정의하고 임포트한다. userId 같은 비표준 키를 새로 만들어 쓰면 발급기와 컨트롤러 어딘가에서 불일치가 발생한다.
User ID와 도메인 ID는 분리해서 다룬다
User와 Operator(혹은 Member·Admin)가 1:1 관계라도 ID는 두 개다. JWT에 들어가는 건 항상 User ID이고, 도메인 매핑 테이블이 가진 건 도메인 ID다. JWT를 받은 컨트롤러는 둘 중 어느 쪽을 쓸지 한 번 의식적으로 결정해야 한다.
권한 헬퍼는 도메인 서비스에 한 곳, 호출은 첫 줄
엔드포인트별로 권한 검증을 깔지 말고 헬퍼 한 곳으로 끌어올린다. 컨트롤러는 첫 줄에서 호출만. 새 엔드포인트가 들어올 때 “권한 체크 누락”을 코드 리뷰에서 잡을 수 있다.
// 새 엔드포인트 추가 시 첫 줄 패턴
async someNewClassEndpoint(@Param("id") id: number, @Req() req: AuthedRequest) {
await this.classAccess.validateClassAccess(id, req.user); // ← 첫 줄 표준
// ... 비즈니스 로직
}
매핑 테이블의 소프트 삭제 컬럼은 헬퍼에서 거른다
ClassOperator.unassignedAt처럼 해지 시각을 기록하는 컬럼이 있으면 헬퍼의 where에 unassignedAt: null을 항상 깐다. 한 번 담당했던 클래스에 영구 접근권이 남는 함정을 막는다.
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| JWT 페이로드 키 | payload.userId(비표준 직접 박기) | payload.sub + RFC 7519 표준 키 |
| User vs 도메인 ID | where: { operatorId: payload.sub } (타입·값 불일치) | operator.findFirst({ where: { userId: payload.sub } }) 후 operator.id |
| 권한 체크 위치 | 컨트롤러 메서드별 인라인 | 도메인 서비스 헬퍼 1개 + 첫 줄 호출 |
| 소프트 삭제 매핑 | where: { classId, operatorId } (해지 행도 통과) | where: { ..., unassignedAt: null } |
| 응답 스펙 | 403 누락(FE가 모름) | @ApiResponse({ status: 403 }) 명시 |
📋 정리
| 항목 | 내용 |
|---|---|
| 표면 증상 | 운영자가 미담당 클래스 ID를 직접 URL에 박아 200 응답 + 정보 노출 |
| 표면 원인 1 | payload.userId를 읽는데 표준 JWT는 sub에 사용자 ID를 담는다 |
| 표면 원인 2 | payload.sub(User ID, CUID) ≠ classOperator.operatorId(Operator ID, Int) |
| 진짜 원인 | 상세·수정·승인 5개 엔드포인트에 공통 권한 헬퍼 부재 + 메서드별로 권한 코드 미작성 |
| 해결 머지 | validateClassAccess 헬퍼 1개 + 다섯 엔드포인트 첫 줄 호출 + 목록 필터 정리 + Swagger 403 |
| 라운드 시간 | 1단계 마감 13:00 → 상세 누락 발견 14:35 → 2단계 마감 14:51 (한 라운드에 닫음) |
목록 API에 필터를 깔았다고 상세까지 막힌 것이 아니다. Refine 같은 어드민 프레임워크는 같은 도메인을 목록·상세·수정·일괄 처리 네다섯 엔드포인트로 독립 노출하기 때문에, 권한은 모델 단위가 아니라 엔드포인트 단위로 깔린다. 그걸 메서드별로 흩뿌리면 한 메서드만 빠뜨려도 우회가 열린다. 헬퍼 1개·호출 N곳이 최소 표준이고, NestJS의 ForbiddenException + Prisma findFirst 두 줄이면 충분히 깔린다.
다음 편(devlog-51)에서는 같은 라운드 직후에 잡힌 콘텐츠 후보 선택 알고리즘의 세 번째 최적화 — 단일 쿼리 + 메모리 필터링으로 옮긴 패턴을 정리한다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날