역할 기반 액세스 제어 설정 방법
이 가이드에서는 TanStack Router 애플리케이션에서 역할 기반 액세스 제어(RBAC)와 권한 기반 라우팅을 구현하는 방법을 다룹니다.
빠른 시작
인증 컨텍스트를 확장해 역할과 권한을 포함하고, 역할로 보호되는 레이아웃 라우트를 만든 다음, 라우트를 렌더링하기 전에 beforeLoad로 사용자 권한을 확인합니다.
인증 컨텍스트 확장
1. 사용자 타입에 역할 추가
인증 컨텍스트를 업데이트해 역할을 포함합니다.
// src/auth.tsx
import React, { createContext, useContext, useState } from 'react'
interface User {
id: string
username: string
email: string
roles: string[]
permissions: string[]
}
interface AuthState {
isAuthenticated: boolean
user: User | null
hasRole: (role: string) => boolean
hasAnyRole: (roles: string[]) => boolean
hasPermission: (permission: string) => boolean
hasAnyPermission: (permissions: string[]) => boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthState | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const hasRole = (role: string) => {
return user?.roles.includes(role) ?? false
}
const hasAnyRole = (roles: string[]) => {
return roles.some((role) => user?.roles.includes(role)) ?? false
}
const hasPermission = (permission: string) => {
return user?.permissions.includes(permission) ?? false
}
const hasAnyPermission = (permissions: string[]) => {
return (
permissions.some((permission) =>
user?.permissions.includes(permission),
) ?? false
)
}
const login = async (username: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
setIsAuthenticated(true)
} else {
throw new Error('Authentication failed')
}
}
const logout = () => {
setUser(null)
setIsAuthenticated(false)
}
return (
<AuthContext.Provider
value={{
isAuthenticated,
user,
hasRole,
hasAnyRole,
hasPermission,
hasAnyPermission,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
2. 라우터 컨텍스트 타입 업데이트
src/routes/__root.tsx를 업데이트합니다.
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface AuthState {
isAuthenticated: boolean
user: {
id: string
username: string
email: string
roles: string[]
permissions: string[]
} | null
hasRole: (role: string) => boolean
hasAnyRole: (roles: string[]) => boolean
hasPermission: (permission: string) => boolean
hasAnyPermission: (permissions: string[]) => boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
interface MyRouterContext {
auth: AuthState
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => (
<div>
<Outlet />
</div>
),
})
역할로 보호되는 라우트 생성
1. 관리자 전용 라우트
src/routes/_authenticated/_admin.tsx를 만듭니다.
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_admin')({
beforeLoad: ({ context, location }) => {
if (!context.auth.hasRole('admin')) {
throw redirect({
to: '/unauthorized',
search: {
redirect: location.href,
},
})
}
},
component: AdminLayout,
})
function AdminLayout() {
return (
<div>
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
<strong>Admin Area:</strong> You have administrative privileges.
</div>
<Outlet />
</div>
)
}
2. 여러 역할 접근
src/routes/_authenticated/_moderator.tsx를 만듭니다.
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_moderator')({
beforeLoad: ({ context, location }) => {
const allowedRoles = ['admin', 'moderator']
if (!context.auth.hasAnyRole(allowedRoles)) {
throw redirect({
to: '/unauthorized',
search: {
redirect: location.href,
reason: 'insufficient_role',
},
})
}
},
component: ModeratorLayout,
})
function ModeratorLayout() {
const { auth } = Route.useRouteContext()
return (
<div>
<div className="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded mb-4">
<strong>Moderator Area:</strong> Role: {auth.user?.roles.join(', ')}
</div>
<Outlet />
</div>
)
}
3. 권한 기반 라우트
src/routes/_authenticated/_users.tsx를 만듭니다.
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_users')({
beforeLoad: ({ context, location }) => {
const requiredPermissions = ['users:read', 'users:write']
if (!context.auth.hasAnyPermission(requiredPermissions)) {
throw redirect({
to: '/unauthorized',
search: {
redirect: location.href,
reason: 'insufficient_permissions',
},
})
}
},
component: () => <Outlet />,
})
특정 보호 페이지 생성
1. 관리자 대시보드
src/routes/_authenticated/_admin/dashboard.tsx를 만듭니다.
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_admin/dashboard')({
component: AdminDashboard,
})
function AdminDashboard() {
const { auth } = Route.useRouteContext()
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">User Management</h2>
<p className="text-gray-600">Manage all users in the system</p>
<button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
View Users
</button>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">System Settings</h2>
<p className="text-gray-600">Configure system-wide settings</p>
<button className="mt-4 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
Open Settings
</button>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">Reports</h2>
<p className="text-gray-600">View system reports and analytics</p>
<button className="mt-4 bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700">
View Reports
</button>
</div>
</div>
<div className="mt-8 bg-gray-100 p-4 rounded">
<h3 className="font-semibold">Your Info:</h3>
<p>Username: {auth.user?.username}</p>
<p>Roles: {auth.user?.roles.join(', ')}</p>
<p>Permissions: {auth.user?.permissions.join(', ')}</p>
</div>
</div>
)
}
2. 사용자 관리 페이지
src/routes/_authenticated/_users/manage.tsx를 만듭니다.
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_users/manage')({
beforeLoad: ({ context }) => {
// Additional permission check at the page level
if (!context.auth.hasPermission('users:write')) {
throw new Error('You need write permissions to manage users')
}
},
component: UserManagement,
})
function UserManagement() {
const { auth } = Route.useRouteContext()
const canEdit = auth.hasPermission('users:write')
const canDelete = auth.hasPermission('users:delete')
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">User Management</h1>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-6 py-4 whitespace-nowrap">John Doe</td>
<td className="px-6 py-4 whitespace-nowrap">john@example.com</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
User
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{canEdit && (
<button className="text-blue-600 hover:text-blue-900 mr-4">
Edit
</button>
)}
{canDelete && (
<button className="text-red-600 hover:text-red-900">
Delete
</button>
)}
</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6 p-4 bg-blue-50 rounded">
<h3 className="font-semibold text-blue-800">Your Permissions:</h3>
<ul className="text-blue-700 text-sm">
{auth.user?.permissions.map((permission) => (
<li key={permission}>✓ {permission}</li>
))}
</ul>
</div>
</div>
)
}
권한 없음 페이지 생성
src/routes/unauthorized.tsx를 만듭니다.
import { createFileRoute, Link } from '@tanstack/react-router'
export const Route = createFileRoute('/unauthorized')({
validateSearch: (search) => ({
redirect: (search.redirect as string) || '/dashboard',
reason: (search.reason as string) || 'insufficient_permissions',
}),
component: UnauthorizedPage,
})
function UnauthorizedPage() {
const { redirect, reason } = Route.useSearch()
const { auth } = Route.useRouteContext()
const reasonMessages = {
insufficient_role: 'You do not have the required role to access this page.',
insufficient_permissions:
'You do not have the required permissions to access this page.',
default: 'You are not authorized to access this page.',
}
const message =
reasonMessages[reason as keyof typeof reasonMessages] ||
reasonMessages.default
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h1>
<p className="text-gray-600 mb-6">{message}</p>
<div className="mb-6 text-sm text-gray-500">
<p>
<strong>Your roles:</strong> {auth.user?.roles.join(', ') || 'None'}
</p>
<p>
<strong>Your permissions:</strong>{' '}
{auth.user?.permissions.join(', ') || 'None'}
</p>
</div>
<div className="space-y-3">
<Link
to="/dashboard"
className="block w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
>
Go to Dashboard
</Link>
<Link
to={redirect}
className="block w-full bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 transition-colors"
>
Try Again
</Link>
</div>
</div>
</div>
)
}
컴포넌트 수준 권한 확인
1. 조건부 렌더링 훅
src/hooks/usePermissions.ts를 만듭니다.
import { useRouter } from '@tanstack/react-router'
export function usePermissions() {
const router = useRouter()
const auth = router.options.context.auth
return {
hasRole: auth.hasRole,
hasAnyRole: auth.hasAnyRole,
hasPermission: auth.hasPermission,
hasAnyPermission: auth.hasAnyPermission,
user: auth.user,
}
}
2. 권한 가드 컴포넌트
src/components/PermissionGuard.tsx를 만듭니다.
interface PermissionGuardProps {
children: React.ReactNode
roles?: string[]
permissions?: string[]
requireAll?: boolean
fallback?: React.ReactNode
}
export function PermissionGuard({
children,
roles = [],
permissions = [],
requireAll = false,
fallback = null,
}: PermissionGuardProps) {
const { hasAnyRole, hasAnyPermission, hasRole, hasPermission } =
usePermissions()
const hasRequiredRoles =
roles.length === 0 ||
(requireAll ? roles.every((role) => hasRole(role)) : hasAnyRole(roles))
const hasRequiredPermissions =
permissions.length === 0 ||
(requireAll
? permissions.every((permission) => hasPermission(permission))
: hasAnyPermission(permissions))
if (hasRequiredRoles && hasRequiredPermissions) {
return <>{children}</>
}
return <>{fallback}</>
}
3. 권한 가드 사용
import { PermissionGuard } from '../components/PermissionGuard'
function SomeComponent() {
return (
<div>
<h1>Dashboard</h1>
<PermissionGuard roles={['admin']}>
<button className="bg-red-600 text-white px-4 py-2 rounded">
Admin Only Button
</button>
</PermissionGuard>
<PermissionGuard
permissions={['users:write']}
fallback={<p className="text-gray-500">You cannot edit users</p>}
>
<button className="bg-blue-600 text-white px-4 py-2 rounded">
Edit Users
</button>
</PermissionGuard>
<PermissionGuard
roles={['admin', 'moderator']}
permissions={['content:moderate']}
requireAll={true}
>
<button className="bg-yellow-600 text-white px-4 py-2 rounded">
Moderate Content (Admin/Mod + Permission)
</button>
</PermissionGuard>
</div>
)
}
고급 권한 패턴
1. 리소스 기반 권한
// Check if user can edit a specific resource
function canEditResource(auth: AuthState, resourceId: string, ownerId: string) {
// Admin can edit anything
if (auth.hasRole('admin')) return true
// Owner can edit their own resources
if (auth.user?.id === ownerId && auth.hasPermission('resource:edit:own'))
return true
// Moderators can edit with permission
if (auth.hasRole('moderator') && auth.hasPermission('resource:edit:any'))
return true
return false
}
// Usage in component
function ResourceEditor({ resource }) {
const { auth } = Route.useRouteContext()
if (!canEditResource(auth, resource.id, resource.ownerId)) {
return <div>You cannot edit this resource</div>
}
return <EditForm resource={resource} />
}
2. 시간 기반 권한
function hasTimeBasedPermission(auth: AuthState, permission: string) {
const userPermissions = auth.user?.permissions || []
const hasPermission = userPermissions.includes(permission)
// Check if permission has time restrictions
const timeRestricted = userPermissions.find((p) =>
p.startsWith(`${permission}:time:`),
)
if (timeRestricted) {
const [, , startHour, endHour] = timeRestricted.split(':')
const currentHour = new Date().getHours()
return (
currentHour >= parseInt(startHour) && currentHour <= parseInt(endHour)
)
}
return hasPermission
}
일반적인 문제
역할/권한 데이터가 로드되지 않음
문제: 라우트에서 사용자 역할/권한이 정의되지 않습니다.
해결 방법: 인증 API가 완전한 사용자 데이터를 반환하는지 확인합니다.
const login = async (username: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (response.ok) {
const userData = await response.json()
// Ensure userData includes roles and permissions
console.log('User data:', userData) // Debug log
setUser(userData)
setIsAuthenticated(true)
}
}
권한 확인이 너무 제한적임
문제: 사용자가 접근해야 하는 영역에 접근하지 못합니다.
해결 방법: 계층적 권한과 역할 상속을 사용합니다.
const roleHierarchy = {
admin: ['admin', 'moderator', 'user'],
moderator: ['moderator', 'user'],
user: ['user'],
}
const hasRole = (requiredRole: string) => {
const userRoles = user?.roles || []
return userRoles.some((userRole) =>
roleHierarchy[userRole]?.includes(requiredRole),
)
}
많은 권한 확인으로 인한 성능 문제
문제: 권한 확인이 너무 많아 렌더링이 느려집니다.
해결 방법: 권한 계산을 메모이제이션합니다.
import { useMemo } from 'react'
function usePermissions() {
const { auth } = Route.useRouteContext()
const permissions = useMemo(
() => ({
canEditUsers: auth.hasPermission('users:write'),
canDeleteUsers: auth.hasPermission('users:delete'),
isAdmin: auth.hasRole('admin'),
isModerator: auth.hasAnyRole(['admin', 'moderator']),
}),
[auth.user?.roles, auth.user?.permissions],
)
return permissions
}
일반적인 다음 단계
RBAC를 설정한 후 다음 작업을 수행할 수 있습니다.
- 기본 인증 설정 방법 - 핵심 인증 구현
- 인증 provider 통합 방법 - 외부 인증 서비스 사용
관련 리소스
- 인증된 라우트 가이드 - 핵심 인증 개념
- 라우터 컨텍스트 가이드 - 라우터 컨텍스트 이해
- RBAC 모범 사례 - 일반적인 RBAC 원칙