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.subUser ID(CUID), classOperator.operatorIdOperator 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 + 승인 반영

다섯 엔드포인트가 같은 패턴으로 뚫렸다. 한 곳 빠진 게 아니라 상세 계열 전부가 권한 체크 없이 노출되고 있었다.

두 운영자가 같은 클래스 ID로 두드렸을 때 목록은 막히지만 상세는 통과되는 패턴이 한눈에 보이던 순간


🔍 탐색 — 잘못된 가설들

원인 후보가 셋이었다. 시간순으로 하나씩 깠다.

가설 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가 안 붙어 있다?

JwtAuthGuardTenantGuard가 빠져서 무인증으로 통과한 줄 알았다. 컨트롤러 데코레이터를 확인했다.

// 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를 담는다는 게 머리에 떠올랐다.

datatracker.ietf.org

sub는 RFC 7519가 정의한 등록된 클레임(registered claim)이다. JWT를 발급하는 라이브러리(@nestjs/jwt 포함)는 보통 sub에 식별자를 담는다. 컨트롤러가 payload.userId를 읽으면 사실상 undefined를 매번 받는다.


🎯 진짜 원인 — JWT 필드 불일치 + Operator ID 분리, 그리고 헬퍼 부재

JWT 페이로드가 표준 sub를 쓴다는 걸 알면서도 컨트롤러에 손으로 적은 payload.userId가 다섯 곳에서 똑같이 undefined를 흘리고 있었다는 걸 깨달은 순간
JWT 페이로드가 표준 sub를 쓴다는 걸 알면서도 컨트롤러에 손으로 적은 payload.userId가 다섯 곳에서 똑같이 undefined를 흘리고 있었다는 걸 깨달은 순간

근본 원인은 두 겹이었다.

원인 A — JWT 필드 불일치 (payload.userIdpayload.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.suboperator.id

userIdsub로 바꾼다고 끝이 아니었다. 다음 한 겹이 더 있었다.

payload.subUser 테이블의 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("이 클래스에 대한 접근 권한이 없습니다");
    }
  }
}

핵심은 세 줄이다.

  1. findFirst({ where: { userId: payload.sub } }) — User ID를 Operator ID로 변환.
  2. findFirst({ where: { classId, operatorId, unassignedAt: null } }) — 매핑 존재 + 해지 안 됨 확인.
  3. 둘 중 하나라도 비면 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.userIdpayload.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(...) { ... }
docs.nestjs.com

✅ 검증 — TC1·TC2 통과, TC3 재검증 PASS

수정 후 QA 재검증을 3개 케이스로 돌렸다.

TC시나리오기대실제
TC1OWNER → 클래스 목록같은 tenant의 모든 클래스✅ 2개 표시
TC2OPERATOR → 클래스 목록본인 담당 클래스만✅ 1개 표시
TC3OPERATOR → 미담당 클래스(/classes/30) 직접 접근403 Forbidden403 — 이 클래스에 대한 접근 권한이 없습니다

같은 운영자 토큰으로 다섯 엔드포인트를 한 번씩 더 두드려서 모두 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 같은 비표준 키를 새로 만들어 쓰면 발급기와 컨트롤러 어딘가에서 불일치가 발생한다.

docs.nestjs.com

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처럼 해지 시각을 기록하는 컬럼이 있으면 헬퍼의 whereunassignedAt: null을 항상 깐다. 한 번 담당했던 클래스에 영구 접근권이 남는 함정을 막는다.

상황안티패턴권장 패턴
JWT 페이로드 키payload.userId(비표준 직접 박기)payload.sub + RFC 7519 표준 키
User vs 도메인 IDwhere: { 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 응답 + 정보 노출
표면 원인 1payload.userId를 읽는데 표준 JWT는 sub에 사용자 ID를 담는다
표면 원인 2payload.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. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날