Guards 시스템 개요
선언적 권한
@api guards 옵션간단한 권한 설정
Guard 타입
admin, user, query타입 정의 제공
커스텀 Guards
비즈니스 로직유연한 확장
계층적 권한
역할 기반 제어RBAC 구현
Guards의 이해
Guards란?
Guards는 API 메서드가 실행되기 전에 권한을 검증하는 함수입니다. 다음과 같은 상황에서 사용합니다:- 로그인한 사용자만 접근 가능한 API
- 관리자만 접근 가능한 API
- 특정 조건을 만족하는 사용자만 접근 가능한 API
- 선언적:
@api({ guards: ["admin"] })처럼 간단하게 선언 - 재사용 가능: 여러 API에서 동일한 Guards 사용
- 테스트 용이: Guards를 독립적으로 테스트 가능
- 관심사 분리: 권한 로직과 비즈니스 로직 분리
Guards의 동작 흐름
복사
1. 클라이언트 → API 요청
2. Sonamu → Context 생성 (user 정보 포함)
3. Guards → 권한 검증
↓ 성공
4. API 메서드 실행
↓
5. 응답 반환
↓ 실패
3. 403 Forbidden 응답
Guard 타입
Sonamu는 세 가지 Guard 타입(user, admin, query)이 정의되어 있습니다. 이들은 타입 안전성을 위한 키이며, 실제 검증 로직은 sonamu.config.ts의 guardHandler에서 구현해야 합니다.
user Guard
user Guard는 로그인한 사용자만 접근할 수 있도록 합니다. 가장 기본적인 인증 Guard입니다.
복사
class PostModel extends BaseModelClass {
/**
* 자신의 게시글 목록 조회
*
* user Guard: 로그인 필수
*/
@api({ httpMethod: "GET", guards: ["user"] })
async getMyPosts(): Promise<{
posts: Post[];
}> {
const context = Sonamu.getContext();
// user Guard 덕분에 context.user가 반드시 존재함
const userId = context.user!.id;
const rdb = this.getPuri("r");
const posts = await rdb
.table("posts")
.where("user_id", userId)
.orderBy("created_at", "desc")
.select("*");
return { posts };
}
/**
* 게시글 작성
*/
@api({ httpMethod: "POST", guards: ["user"] })
async create(params: {
title: string;
content: string;
}): Promise<{ postId: number }> {
const context = Sonamu.getContext();
const { title, content } = params;
const wdb = this.getPuri("w");
const [post] = await wdb
.table("posts")
.insert({
user_id: context.user!.id,
title,
content,
created_at: new Date(),
})
.returning({ id: "id" });
return { postId: post.id };
}
}
- 로그인이 필요한 모든 API
- 사용자별 데이터를 다루는 API (내 프로필, 내 주문 등)
- 콘텐츠 생성/수정/삭제
admin Guard
admin Guard는 관리자만 접근할 수 있도록 합니다. 민감한 관리 기능에 사용합니다.
복사
class UserModel extends BaseModelClass {
/**
* 모든 사용자 목록 (관리자 전용)
*/
@api({ httpMethod: "GET", guards: ["admin"] })
async getAllUsers(params: {
page?: number;
pageSize?: number;
}): Promise<{
users: User[];
total: number;
}> {
const { page = 1, pageSize = 20 } = params;
const rdb = this.getPuri("r");
const users = await rdb
.table("users")
.limit(pageSize)
.offset((page - 1) * pageSize)
.select("*");
const [{ count }] = await rdb
.table("users")
.count("* as count");
return {
users,
total: count,
};
}
/**
* 사용자 강제 비활성화 (관리자 전용)
*/
@api({ httpMethod: "POST", guards: ["admin"] })
async deactivateUser(userId: number): Promise<{ success: boolean }> {
const wdb = this.getPuri("w");
await wdb
.table("users")
.where("id", userId)
.update({
is_active: false,
deactivated_at: new Date(),
});
return { success: true };
}
}
- 시스템 설정 변경
- 모든 사용자 데이터 접근
- 사용자 관리 (활성화/비활성화, 권한 변경)
- 통계 및 분석 데이터
- 민감한 비즈니스 로직
query Guard
query Guard는 쿼리 스트링 파라미터로 간단한 인증을 구현합니다. 주로 공개 API나 임시 링크에 사용합니다.
복사
class FileModel extends BaseModelClass {
/**
* 파일 다운로드 (쿼리 토큰 인증)
*
* 사용 예: GET /api/file/download?fileId=123&token=abc...
*/
@api({ httpMethod: "GET", guards: ["query"] })
async download(params: {
fileId: number;
token: string;
}): Promise<{
url: string;
}> {
const { fileId, token } = params;
// 토큰 검증
const rdb = this.getPuri("r");
const downloadToken = await rdb
.table("download_tokens")
.where("file_id", fileId)
.where("token", token)
.where("expires_at", ">", new Date())
.first();
if (!downloadToken) {
throw new Error("Invalid or expired download token");
}
// 파일 정보 조회
const file = await rdb
.table("files")
.where("id", fileId)
.first();
if (!file) {
throw new Error("File not found");
}
// Signed URL 생성
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getSignedUrl(file.key, 3600);
return { url };
}
}
- 이메일 링크로 전송되는 임시 다운로드 링크
- 공유 가능한 공개 API
- Webhook 콜백 (API 키 검증)
- 단순한 인증이 필요한 경우
query Guard의 구체적인 구현은 프로젝트마다 다를 수 있습니다. API 키, 토큰, 서명 등 다양한 방식으로 확장 가능합니다.여러 Guards 조합
하나의 API에 여러 Guards를 적용할 수 있습니다. 모든 Guards를 통과해야 API가 실행됩니다.복사
class ReportModel extends BaseModelClass {
/**
* 민감한 리포트 생성 (admin + 추가 검증)
*/
@api({ httpMethod: "POST", guards: ["admin", "superAdmin"] })
async generateSensitiveReport(): Promise<{ reportId: number }> {
// admin Guard와 superAdmin Guard를 모두 통과해야 실행됨
// ...
}
}
Guards 구현
Guards는sonamu.config.ts의 guardHandler에서 구현합니다. Sonamu가 각 API 호출 시 자동으로 guardHandler를 실행합니다.
guardHandler 구현
복사
// sonamu.config.ts
import type { GuardKey } from "sonamu";
import type { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";
export default {
// ... 다른 설정
api: {
route: {
prefix: "/api",
},
server: {
custom: (server) => {
// Fastify 서버 커스터마이징
},
},
apiConfig: {
contextProvider: (defaultContext, request) => ({
...defaultContext,
// Context 확장
}),
/**
* Guards 검증 로직
*
* @param guard - Guard 키 ("user", "admin", "query" 등)
* @param request - Fastify Request 객체
* @param api - API 메타데이터
* @returns true면 통과, false나 예외 발생 시 차단
*/
guardHandler: (guard: GuardKey, request: FastifyRequest, api) => {
// Sonamu Context에서 사용자 정보 가져오기
const context = Sonamu.getContext();
switch (guard) {
case "user":
// 로그인한 사용자만 접근 가능
if (!context.user) {
throw new Error("Authentication required");
}
return true;
case "admin":
// 관리자만 접근 가능
if (!context.user || context.user.role !== "admin") {
throw new Error("Admin access required");
}
return true;
case "query":
// 쿼리 파라미터로 API 키 검증
const apiKey = request.query.apiKey;
if (apiKey !== process.env.API_KEY) {
throw new Error("Invalid API key");
}
return true;
default:
// 알 수 없는 Guard는 차단
throw new Error(`Unknown guard: ${guard}`);
}
},
},
},
};
커스텀 Guard 타입 추가
프로젝트에 특화된 Guard를 추가하려면GuardKeys 인터페이스를 확장합니다.
복사
// types/guards.ts
declare module "sonamu" {
interface GuardKeys {
verified: true; // 이메일 인증된 사용자
premium: true; // 프리미엄 회원
moderator: true; // 중재자
}
}
guardHandler에서 새로운 Guard를 구현합니다.
복사
// sonamu.config.ts
guardHandler: (guard, request, api) => {
const context = Sonamu.getContext();
switch (guard) {
case "user":
if (!context.user) {
throw new Error("Authentication required");
}
return true;
case "admin":
if (!context.user || context.user.role !== "admin") {
throw new Error("Admin access required");
}
return true;
case "verified":
// 이메일 인증된 사용자만
if (!context.user || !context.user.email_verified) {
throw new Error("Email verification required");
}
return true;
case "premium":
// 프리미엄 회원만
if (!context.user || context.user.subscription_tier !== "premium") {
throw new Error("Premium subscription required");
}
return true;
case "moderator":
// 중재자 또는 관리자
if (!context.user ||
(context.user.role !== "moderator" && context.user.role !== "admin")) {
throw new Error("Moderator access required");
}
return true;
default:
throw new Error(`Unknown guard: ${guard}`);
}
},
동적 검증이 필요한 경우
API 파라미터에 따라 동적으로 권한을 검증해야 하는 경우,guardHandler에서 api 파라미터를 활용할 수 있습니다.
복사
guardHandler: (guard, request, api) => {
const context = Sonamu.getContext();
if (guard === "owner") {
// API 메타데이터를 활용한 동적 검증
// 예: 게시글 ID가 파라미터에 있는지 확인
const postId = request.query.postId || request.body?.postId;
if (!postId) {
throw new Error("Resource ID required");
}
// 여기서는 간단한 검증만 수행
// 복잡한 소유권 검증은 API 메서드 내부에서 수행하는 것이 좋습니다
if (!context.user) {
throw new Error("Authentication required");
}
return true;
}
// ... 다른 Guards
},
주의: 복잡한 비즈니스 로직이나 데이터베이스 조회가 필요한 권한 검증은 Guards가 아닌 API 메서드 내부에서 처리하는 것이 좋습니다. Guards는 간단한 인증/권한 확인만 담당해야 합니다.
역할 기반 접근 제어 (RBAC)
더 복잡한 권한 시스템을 구현하려면 RBAC(Role-Based Access Control)를 사용합니다.역할과 권한 정의
복사
// types/permissions.ts
export enum Permission {
// 사용자 관리
USER_READ = "user:read",
USER_WRITE = "user:write",
USER_DELETE = "user:delete",
// 게시글 관리
POST_READ = "post:read",
POST_WRITE = "post:write",
POST_DELETE = "post:delete",
// 관리
ADMIN_DASHBOARD = "admin:dashboard",
ADMIN_SETTINGS = "admin:settings",
}
export const rolePermissions: Record<string, Permission[]> = {
admin: [
Permission.USER_READ,
Permission.USER_WRITE,
Permission.USER_DELETE,
Permission.POST_READ,
Permission.POST_WRITE,
Permission.POST_DELETE,
Permission.ADMIN_DASHBOARD,
Permission.ADMIN_SETTINGS,
],
moderator: [
Permission.POST_READ,
Permission.POST_WRITE,
Permission.POST_DELETE,
],
user: [
Permission.POST_READ,
Permission.POST_WRITE,
],
guest: [
Permission.POST_READ,
],
};
/**
* 사용자가 특정 권한을 가지고 있는지 확인
*/
export function hasPermission(
user: User | null,
permission: Permission
): boolean {
if (!user) return false;
const permissions = rolePermissions[user.role] || [];
return permissions.includes(permission);
}
권한 기반 Guard 구현
RBAC 시스템을 Guard에 통합하려면guardHandler에서 권한을 검증합니다.
복사
// sonamu.config.ts
import { Permission, hasPermission } from "./types/permissions";
// GuardKeys에 권한 기반 Guard 추가
declare module "sonamu" {
interface GuardKeys {
"user:write": true;
"user:delete": true;
"post:delete": true;
"admin:settings": true;
}
}
export default {
// ... 설정
api: {
apiConfig: {
guardHandler: (guard, request, api) => {
const context = Sonamu.getContext();
// 기본 Guards
switch (guard) {
case "user":
if (!context.user) {
throw new Error("Authentication required");
}
return true;
case "admin":
if (!context.user || context.user.role !== "admin") {
throw new Error("Admin access required");
}
return true;
// 권한 기반 Guards
case "user:write":
if (!hasPermission(context.user, Permission.USER_WRITE)) {
throw new Error("USER_WRITE permission required");
}
return true;
case "user:delete":
if (!hasPermission(context.user, Permission.USER_DELETE)) {
throw new Error("USER_DELETE permission required");
}
return true;
case "post:delete":
if (!hasPermission(context.user, Permission.POST_DELETE)) {
throw new Error("POST_DELETE permission required");
}
return true;
case "admin:settings":
if (!hasPermission(context.user, Permission.ADMIN_SETTINGS)) {
throw new Error("ADMIN_SETTINGS permission required");
}
return true;
default:
throw new Error(`Unknown guard: ${guard}`);
}
},
},
},
};
권한 Guard 사용
복사
class UserModel extends BaseModelClass {
/**
* 사용자 정보 수정 (관리자 또는 본인)
*/
@api({ httpMethod: "PUT", guards: ["user:write"] })
async updateUser(params: {
userId: number;
username?: string;
email?: string;
}): Promise<{ user: User }> {
const context = Sonamu.getContext();
const { userId, username, email } = params;
// 본인이 아니고 관리자도 아니면 거부
if (
context.user!.id !== userId &&
!hasPermission(context.user, Permission.USER_WRITE)
) {
throw new Error("Permission denied");
}
const wdb = this.getPuri("w");
await wdb
.table("users")
.where("id", userId)
.update({
username,
email,
updated_at: new Date(),
});
const rdb = this.getPuri("r");
const user = await rdb
.table("users")
.where("id", userId)
.first();
return { user };
}
}
리소스 기반 권한 제어
특정 리소스에 대한 소유권을 확인하는 패턴입니다.복사
class PostModel extends BaseModelClass {
/**
* 게시글 수정 (작성자 또는 관리자만)
*/
@api({ httpMethod: "PUT", guards: ["user"] })
async update(params: {
postId: number;
title?: string;
content?: string;
}): Promise<{ post: Post }> {
const context = Sonamu.getContext();
const { postId, title, content } = params;
// 게시글 조회
const rdb = this.getPuri("r");
const post = await rdb
.table("posts")
.where("id", postId)
.first();
if (!post) {
throw new Error("Post not found");
}
// 소유권 확인
const isOwner = post.user_id === context.user!.id;
const isAdmin = context.user!.role === "admin";
if (!isOwner && !isAdmin) {
throw new Error("You can only edit your own posts");
}
// 수정
const wdb = this.getPuri("w");
await wdb
.table("posts")
.where("id", postId)
.update({
title,
content,
updated_at: new Date(),
});
const updatedPost = await rdb
.table("posts")
.where("id", postId)
.first();
return { post: updatedPost };
}
/**
* 게시글 삭제 (작성자 또는 관리자만)
*/
@api({ httpMethod: "DELETE", guards: ["user"] })
async remove(postId: number): Promise<{ success: boolean }> {
const context = Sonamu.getContext();
const rdb = this.getPuri("r");
const post = await rdb
.table("posts")
.where("id", postId)
.first();
if (!post) {
throw new Error("Post not found");
}
// 소유권 확인
if (
post.user_id !== context.user!.id &&
context.user!.role !== "admin"
) {
throw new Error("Permission denied");
}
const wdb = this.getPuri("w");
await wdb.table("posts").where("id", postId).delete();
return { success: true };
}
}
Guards 테스트
Guards를 독립적으로 테스트할 수 있습니다.복사
// guards/__tests__/guards.test.ts
import { describe, it, expect } from "vitest";
import { guards } from "../index";
import type { AppContext } from "../../types/context";
describe("Guards", () => {
describe("user guard", () => {
it("로그인한 사용자는 통과", () => {
const context: Partial<AppContext> = {
user: {
id: 1,
email: "test@example.com",
role: "user",
} as any,
};
expect(guards.user(context as AppContext)).toBe(true);
});
it("로그인하지 않은 사용자는 차단", () => {
const context: Partial<AppContext> = {
user: null,
};
expect(guards.user(context as AppContext)).toBe(false);
});
});
describe("admin guard", () => {
it("관리자는 통과", () => {
const context: Partial<AppContext> = {
user: {
id: 1,
email: "admin@example.com",
role: "admin",
} as any,
};
expect(guards.admin(context as AppContext)).toBe(true);
});
it("일반 사용자는 차단", () => {
const context: Partial<AppContext> = {
user: {
id: 2,
email: "user@example.com",
role: "user",
} as any,
};
expect(guards.admin(context as AppContext)).toBe(false);
});
});
});
주의사항
Guards 사용 시 주의사항:
- Guards는 인증(Authentication)만 담당, 인가(Authorization)는 비즈니스 로직에서 처리
- 민감한 데이터는 Guards 통과 후에도 추가 검증 필수
- Guards 실패 시 403 Forbidden 반환 (401 Unauthorized 아님)
- 여러 Guards 사용 시 순서 고려 (일반적인 것부터 구체적인 순서로)
- Guards에서는 복잡한 비즈니스 로직 지양 (단순 권한 확인만)
- 리소스 소유권 확인은 API 메서드 내부에서 처리