2.4 페이지/컴포넌트 가드 훅 구현(usePermission)

This commit is contained in:
koreacomp5
2025-10-09 14:46:16 +09:00
parent c34a814d28
commit 03924c45b0
5 changed files with 85 additions and 3 deletions

View File

@@ -1,8 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { requirePermission } from "@/lib/rbac";
import { getUserIdFromRequest } from "@/lib/auth";
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; const { id } = await context.params;
const userId = getUserIdFromRequest(req);
try {
await requirePermission({ userId, resource: "BOARD", action: "MODERATE" });
} catch (e: any) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const post = await prisma.post.update({ const post = await prisma.post.update({
where: { id }, where: { id },
data: { status: "published" }, data: { status: "published" },

View File

@@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import { requirePermission } from "@/lib/rbac";
import { getUserIdFromRequest } from "@/lib/auth";
const schema = z.object({ pinned: z.boolean(), order: z.number().int().nullable().optional() }); const schema = z.object({ pinned: z.boolean(), order: z.number().int().nullable().optional() });
@@ -10,6 +12,12 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
const parsed = schema.safeParse(body); const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { pinned, order } = parsed.data; const { pinned, order } = parsed.data;
const userId = getUserIdFromRequest(req);
try {
await requirePermission({ userId, resource: "BOARD", action: "MODERATE" });
} catch (e: any) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const post = await prisma.post.update({ const post = await prisma.post.update({
where: { id }, where: { id },
data: { isPinned: pinned, pinnedOrder: pinned ? order ?? 0 : null }, data: { isPinned: pinned, pinnedOrder: pinned ? order ?? 0 : null },

11
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,11 @@
// 간이 인증 헬퍼: 실제 세션/쿠키 연동 전 임시로 헤더에서 userId를 읽어옵니다.
export function getUserIdFromRequest(req: Request): string | null {
try {
const id = req.headers.get("x-user-id");
return id && id.length > 0 ? id : null;
} catch {
return null;
}
}

55
src/lib/rbac.ts Normal file
View File

@@ -0,0 +1,55 @@
import prisma from "@/lib/prisma";
// 권한 확인: userId가 주어지면 역할 → 권한 매핑(RolePermission)으로 검사
export async function checkPermission(options: {
userId: string | null | undefined;
resource: "BOARD" | "POST" | "COMMENT" | "USER" | "ADMIN";
action: "READ" | "CREATE" | "UPDATE" | "DELETE" | "MODERATE" | "ADMINISTER";
}): Promise<boolean> {
const { userId, resource, action } = options;
if (!userId) return false;
const userRoles = await prisma.userRole.findMany({
where: { userId },
select: { roleId: true },
});
if (userRoles.length === 0) return false;
const roleIds = userRoles.map((r) => r.roleId);
const has = await prisma.rolePermission.findFirst({
where: {
roleId: { in: roleIds },
resource,
action,
allowed: true,
},
select: { id: true },
});
if (has) return true;
// ADMIN.ADMINISTER 이면 모든 리소스/액션 허용
const isAdmin = await prisma.rolePermission.findFirst({
where: {
roleId: { in: roleIds },
resource: "ADMIN",
action: "ADMINISTER",
allowed: true,
},
select: { id: true },
});
return !!isAdmin;
}
export async function requirePermission(options: {
userId: string | null | undefined;
resource: "BOARD" | "POST" | "COMMENT" | "USER" | "ADMIN";
action: "READ" | "CREATE" | "UPDATE" | "DELETE" | "MODERATE" | "ADMINISTER";
}): Promise<void> {
const ok = await checkPermission(options);
if (!ok) {
const err = new Error("Forbidden");
// @ts-expect-error attach status for route handlers
err.status = 403;
throw err;
}
}

View File

@@ -7,8 +7,8 @@
[권한/역할(RBAC)] [권한/역할(RBAC)]
2.1 역할·권한 시드(admin/editor/user) 추가 o 2.1 역할·권한 시드(admin/editor/user) 추가 o
2.2 권한 enum/매핑 정의(리소스/액션) 2.2 권한 enum/매핑 정의(리소스/액션) o
2.3 서버 권한 미들웨어 적용(API 보호 라우트 지정) 2.3 서버 권한 미들웨어 적용(API 보호 라우트 지정) o
2.4 페이지/컴포넌트 가드 훅 구현(usePermission) 2.4 페이지/컴포넌트 가드 훅 구현(usePermission)
2.5 권한 기반 UI 노출 제어(빠른 액션/관리자 메뉴) 2.5 권한 기반 UI 노출 제어(빠른 액션/관리자 메뉴)