역할 기반 접근 제어 (RBAC) 구현
인증으로 만든 현재 사용자와 라우트 역할 메타데이터를 분리하고, 전역 가드의 기본 보호 정책과 명시적 공개 경계를 구현합니다.
지난 절에서는 JWT access token을 검증해 현재 요청의 req.user를 만드는 인증(Authentication) 경계를 구현했습니다.
이번 절의 권한 부여(Authorization)는 인증된 사용자가 특정 라우트를 실행할 수 있는지 판단합니다. 인증 실패는 “누구인지 확인할 수 없음”이므로 401 Unauthorized, 역할 부족은 “누구인지는 알지만 이 작업은 허용되지 않음”이므로 403 Forbidden 경계입니다.
RBAC에서 비교하는 두 역할 집합
역할 기반 접근 제어(Role-Based Access Control, RBAC)는 사용자에게 개별 권한을 직접 붙이는 대신 역할을 부여하고, 역할을 권한 집합과 연결하는 모델입니다.
- 사용자(User): 인증된 주체입니다.
- 역할(Role):
admin,editor,user처럼 직무나 책임을 나타내는 이름입니다. - 권한(Permission):
create:post,read:post처럼 리소스에 수행할 수 있는 구체적인 행위입니다.
이 절의 코드는 라우트가 요구하는 역할과 사용자의 역할이 하나라도 정확히 일치하는지 확인하는 기본 RBAC입니다. 역할에서 권한으로 확장하는 매핑이나 역할 계층은 자동으로 생기지 않습니다. 따라서 admin이 user를 암묵적으로 포함하지 않으며, 필요한 경우 중앙 정책에서 명시적으로 설계해야 합니다.
Nest · RBAC Policy Contract
익명 접근, 인증 전용, 역할 제한을 서로 다른 정책 상태로 선언하고, 역할 비교 의미와 변경 시점을 한곳에서 관리합니다.
| 정책 경계 | 선언·입력 | 실행 계약과 결과 |
|---|---|---|
| 명시적 공개 | @Public() |
두 전역 guard가 공개 메타데이터를 먼저 확인합니다. handler의 공개 예외는 controller에서 상속된 @Roles()보다 우선합니다. |
| 기본 인증 | 전역 JwtAuthGuard |
token 누락·검증 실패는 401입니다. 인증 성공 뒤에만 req.user가 역할 가드로 전달됩니다. |
| 요구 역할 조회 | handler·class의 @Roles() |
getAllAndOverride()는 handler 값을 우선하고 class 값을 fallback으로 사용합니다. 두 배열을 합치지 않습니다. |
| 메타데이터 없음 | undefined |
추가 역할 제한 없이 통과합니다. 앞선 전역 인증은 유지되므로 공개가 아니라 인증 전용입니다. |
| 역할 판정 | some() |
요구 역할 중 하나와 정확히 일치하면 handler를 실행하고, 불일치하면 403입니다. 암묵적 역할 계층은 없습니다. |
| 변경과 신선도 | Role 어휘, 사용자 데이터, JWT |
역할 이름을 한 모델로 유지합니다. JWT 역할은 발급 시점 스냅샷이므로 즉시 변경이 필요하면 조회나 폐기 상태를 추가합니다. |
- 명시적 공개 ·
@Public() - 두 전역 guard가 먼저 통과합니다. handler의 공개 예외는 controller에서 상속된
@Roles()보다 우선합니다. - 기본 인증 · 전역
JwtAuthGuard - token 누락·검증 실패는
401입니다. 성공 뒤에만req.user가 역할 가드로 전달됩니다. - 요구 역할 · handler override
getAllAndOverride()는 handler를 먼저, class를 fallback으로 읽으며 두 역할 배열을 합치지 않습니다.- 메타데이터 없음 · 인증 전용
requiredRoles값이undefined이면 역할 제한만 생략합니다. 전역 JWT 인증은 그대로 적용됩니다.- exact any-of · 일치 또는
403 - 요구 역할 중 하나와 정확히 일치해야 합니다.
admin이user를 자동으로 포함하지 않습니다. - 역할 변경 · token 신선도
- 역할 어휘와 데이터를 함께 바꾸고, 발급된 JWT의 오래된 역할을 언제 조회·만료·폐기할지 정합니다.
이 표의 정책은 “기본 인증, 명시적 공개, 필요한 곳의 역할 제한”입니다. 모든 route가 권한 메타데이터를 반드시 선언해야 하는 조직이라면 별도의 policy-required 검사를 추가합니다.
이 구현의 공개·보호 계약은 다음과 같습니다.
JwtAuthGuard를 전역으로 등록해 모든 라우트를 기본적으로 인증 필요 상태로 둡니다.- 익명 접근은
@Public()을 붙인 라우트에서만 명시적으로 허용하고, 두 전역 가드가 같은 공개 메타데이터를 먼저 확인합니다. @Roles()가 없다는 것은 “공개”가 아니라 “인증만 필요하고 추가 역할 제한은 없음”을 뜻합니다.@Roles(Role.User, Role.Editor)는 둘 중 하나를 가진 사용자를 허용하는 any-of 규칙입니다.
하나의 역할 어휘 정의
문자열을 여러 파일에서 직접 반복하지 않도록 역할을 한곳에서 정의합니다.
export enum Role {
User = 'user',
Editor = 'editor',
Admin = 'admin',
}사용자 저장소, JWT claim 검증, @Roles() 메타데이터와 테스트가 모두 이 어휘를 사용해야 합니다. 데이터베이스에서 문자열을 읽는다면 애플리케이션 경계에서 허용된 Role 값인지 검증합니다.
지난 절의 JwtStrategy.validate(payload: unknown)도 단순히 roles가 문자열 배열인지만 보지 말고, 현재 애플리케이션이 아는 역할인지 확인하도록 좁힙니다.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Role } from '../role.enum';
type JwtPayload = {
sub: number;
username: string;
roles: Role[];
};
const roleValues = new Set<string>(Object.values(Role));
function isJwtPayload(value: unknown): value is JwtPayload {
if (typeof value !== 'object' || value === null) return false;
const payload = value as Record<string, unknown>;
return (
typeof payload.sub === 'number' &&
Number.isSafeInteger(payload.sub) &&
typeof payload.username === 'string' &&
Array.isArray(payload.roles) &&
payload.roles.every(
(role): role is Role =>
typeof role === 'string' && roleValues.has(role),
)
);
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey:
configService.getOrThrow<string>('JWT_SECRET'),
algorithms: ['HS256'],
ignoreExpiration: false,
});
}
validate(payload: unknown) {
if (!isJwtPayload(payload)) {
throw new UnauthorizedException('Invalid JWT claims');
}
return {
userId: payload.sub,
username: payload.username,
roles: payload.roles,
};
}
}Passport JWT가 허용 알고리즘·서명·만료를 검증한 뒤에만 validate()가 호출됩니다. 위 검사는 그 다음 애플리케이션 경계에서 역할 claim의 런타임 형태와 어휘를 검증합니다.
JWT의 역할은 발급 시점의 스냅샷입니다. 관리자가 역할을 변경해도 기존 token에는 만료 전까지 이전 값이 남을 수 있습니다. 즉시 반영이 필요하면 짧은 access token 수명만 믿지 말고 요청 중 사용자 저장소 조회, token version 또는 별도 폐기 상태 같은 정책을 선택합니다.
역할과 공개 메타데이터
@Roles()는 필요한 역할 배열을, @Public()은 익명 접근 예외를 메타데이터로 기록합니다.
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../auth/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) =>
SetMetadata(ROLES_KEY, roles);import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);SetMetadata()는 정책을 실행하지 않습니다. 현재 handler와 controller class에 데이터를 붙일 뿐이며, 실제 판정은 가드가 담당합니다.
@Public()과 @Roles()는 서로 반대되는 정책이므로 같은 대상에 함께 붙이지 않습니다. 다만 @Roles()가 붙은 controller 안에서 특정 handler만 @Public()으로 여는 구성은 유효합니다. 이때 두 전역 가드 모두 handler의 공개 메타데이터를 먼저 읽어야 class의 역할 제한이 익명 요청을 다시 거부하지 않습니다. 반대로 controller 전체에 @Public()을 붙이면 handler의 @Roles()만으로 다시 보호할 수 없으므로, 공개 범위는 가능한 한 handler 단위로 좁게 선언합니다.
기본 인증 가드와 명시적 공개 경계
Passport의 JWT 가드를 확장해 @Public() 메타데이터가 있는 handler 또는 controller만 인증을 건너뛰게 합니다.
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import {
IS_PUBLIC_KEY,
} from '../../common/decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) return true;
return super.canActivate(context);
}
}getAllAndOverride()는 배열 앞쪽에서 처음 정의된 값을 사용합니다. 따라서 handler 메타데이터를 먼저, controller class를 나중에 전달하면 handler 설정이 class 기본값을 대체합니다.
RolesGuard 구현
RolesGuard는 handler가 요구하는 역할을 먼저 찾고, 없으면 controller class의 기본 역할을 사용합니다.
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../role.enum';
import {
IS_PUBLIC_KEY,
} from '../../common/decorators/public.decorator';
import {
ROLES_KEY,
} from '../../common/decorators/roles.decorator';
type AuthenticatedRequest = {
user?: {
userId: number;
username: string;
roles: Role[];
};
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const targets = [context.getHandler(), context.getClass()];
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
targets,
);
if (isPublic) return true;
const requiredRoles =
this.reflector.getAllAndOverride<Role[]>(
ROLES_KEY,
targets,
);
if (requiredRoles === undefined) {
return true;
}
const { user } =
context.switchToHttp().getRequest<AuthenticatedRequest>();
return (
user !== undefined &&
requiredRoles.some((role) => user.roles.includes(role))
);
}
}중요한 경계는 다음과 같습니다.
- 유효한
@Public()메타데이터가 있으면 역할 조회보다 먼저 통과합니다. 따라서@Roles()controller 안의 공개 handler도 두 전역 가드를 모두 건너뜁니다. - handler의
@Roles()는 class의@Roles()를 대체합니다. 둘을 합치려면getAllAndMerge()를 선택하고 그 결과가 OR인지 AND인지 별도로 정의해야 합니다. - 메타데이터가 없을 때
RolesGuard가true를 반환해도 앞선 전역JwtAuthGuard는 이미 인증을 요구했습니다. 따라서 이 라우트는 공개가 아니라 인증 전용입니다. - 필요한 역할이 있고 교집합이 없으면
false가 반환되며 Nest는 기본적으로403 Forbidden을 응답합니다. - 인수 없는
@Roles()는 빈 배열을 저장하고 어떤 사용자도 일치하지 않으므로 사용하지 않습니다.
전역 가드 등록
두 가드를 APP_GUARD로 등록하면 모듈 위치와 관계없이 애플리케이션 전체에 적용됩니다. 가드는 바인딩된 순서로 실행되므로 JWT 인증 가드를 역할 가드보다 먼저 둡니다.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
// imports, controllers 생략
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AuthModule {}전역 등록을 사용하지 않는다면 보호할 handler에 @UseGuards(JwtAuthGuard, RolesGuard)를 이 순서로 붙여야 합니다. 일부 라우트에서 역할 가드만 실행해 req.user가 아직 없는 상태를 만들지 않습니다.
Controller 정책 선언
import { Controller, Get, Request } from '@nestjs/common';
import { Role } from './auth/role.enum';
import { Public } from './common/decorators/public.decorator';
import { Roles } from './common/decorators/roles.decorator';
@Controller()
export class AppController {
@Public()
@Get()
getHello() {
return { message: 'public endpoint' };
}
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
@Roles(Role.Admin)
@Get('admin-dashboard')
getAdminDashboard() {
return { message: 'admin only' };
}
@Roles(Role.User, Role.Editor)
@Get('user-content')
getUserContent() {
return { message: 'user or editor' };
}
}profile에는 @Roles()가 없지만 전역 JWT 가드 때문에 인증은 필요합니다. user-content는 user 또는 editor 중 하나를 요구합니다. 이 예제에는 역할 계층이 없으므로 admin만 가진 사용자는 자동으로 통과하지 않습니다.
Nest · Request Enforcement Flow
익명 예외를 먼저 확인하고, 나머지는 인증으로 현재 사용자를 만든 뒤 역할 메타데이터를 판정합니다.
-
@Public()예외를 확인합니다.두 전역 guard가 같은 공개 메타데이터를 먼저 확인하므로, class의 역할 제한이 있어도 공개 handler를 실행합니다.
-
나머지 요청은 JWT로 인증합니다.
token 누락·검증 실패는
401입니다. 성공하면req.user.roles가 준비됩니다. -
Reflector가 역할 메타데이터를 읽습니다.handler 값을 class 기본값보다 먼저 사용합니다.
-
@Roles()가 없으면 인증 전용으로 통과합니다.역할 제한만 생략하며 공개로 바뀌지 않습니다.
-
요구 역할이 있으면 exact any-of로 비교합니다.
하나라도 일치하면 handler를 실행하고, 일치하지 않으면
403입니다.
인증은 현재 사용자를 만들고, 권한 부여는 route 정책과 그 사용자를 비교합니다. admin이 다른 역할을 자동 포함하는 계층은 이 흐름에 없습니다.
경계 테스트
같은 경로의 성공 사례만 확인하지 말고 공개·인증·인가 실패를 나눠 검증합니다.
| 요청 | 사용자 상태 | 예상 결과 |
|---|---|---|
GET / | token 없음 | 200 OK · @Public() |
class에 @Roles(Role.Admin), handler에 @Public() | token 없음 | 200 OK · 공개 정책이 상속된 역할 제한보다 우선 |
GET /profile | token 없음 또는 유효하지 않음 | 401 Unauthorized |
GET /profile | 유효한 user token | 200 OK · 인증 전용 |
GET /admin-dashboard | user 역할 | 403 Forbidden |
GET /admin-dashboard | admin 역할 | 200 OK |
GET /user-content | admin 역할만 보유 | 403 Forbidden · 계층 없음 |
역할 이름을 변경할 때는 enum만 바꾸고 끝내지 않습니다. 사용자 데이터와 seed, 관리 UI, 발급 로직, 라우트 메타데이터, 기존 JWT의 신선도 정책, 허용·401·403 테스트를 같은 변경 단위로 검토합니다.
세밀한 리소스 행위나 소유권 조건이 필요해지면 역할 문자열을 계속 늘리기보다 permission claim 또는 정책 기반 권한 부여로 확장합니다.
공식 문서
- NestJS Authorization: 기본 RBAC guard, 역할 metadata와
403 Forbidden - NestJS Execution context:
Reflector,getAllAndOverride(),getAllAndMerge() - NestJS Request lifecycle: global·controller·route guard 실행 순서
- NestJS Authentication: global authentication guard와 명시적
@Public()경계
RBAC 구현은 인증이 만든 신뢰 가능한 현재 사용자, 라우트 메타데이터, 비교 의미와 공개 정책이 같은 계약을 바라볼 때 안정적으로 동작합니다.
다음 절에서는 외부 제공자 계정으로 로그인하는 OAuth 흐름을 연결합니다.