# Laravel 13 관리자 대시보드 - operator(부운영자) 권한 제어 및 보안 구축 가이드

## 1. 개요
본 문서는 Laravel 13, Spatie Permission 패키지, Ubuntu VPS 환경에서 개발 중인 GLOBEAU 관리자 대시보드의 **operator(부운영자) 역할 기반 접근 제어(RBAC) 및 보안 아키텍처** 구축 내역을 정리한 종합 가이드입니다. 프론트엔드 UI부터 백엔드 라우트, 컨트롤러 내부 방어벽에 이르는 다중 방어선(Defense in Depth) 구축 방안을 담고 있습니다.

---

## 2. 권한 제어 아키텍처 (다중 방어선)
보안과 사용자 경험(UX)을 모두 충족하기 위해 총 3단계의 방어선을 구축하였습니다.

1. **사이드바 및 뷰 레벨 (`@can` 및 동적 필터링):**
   - 권한이 없는 메뉴나 버튼은 사용자 화면에서 원천적으로 노출되지 않도록 차단하여 사용자 경험을 보호합니다.
   - 대메뉴는 항상 노출되도록 유지하되, 하위 메뉴(`children`) 중 `permission`이 지정되었으나 권한이 없는 항목은 자동으로 필터링하여 숨깁니다.
2. **라우트 레벨 미들웨어 (`permission:권한키`):**
   - 주소창을 통한 직접 접근(우회 접근) 시도 시 Spatie 미들웨어가 즉시 가로채어 `403 Forbidden` 에러를 반환합니다.
3. **컨트롤러 내부 방어벽 (`$this->authorizeTargetUser`):**
   - API 요청이나 툴을 이용한 우회 시도 및 타 관리자/운영자 정보 유출을 막기 위해 컨트롤러 레벨에서 타겟 데이터를 철저히 검증합니다.

---

## 3. 사이드바 뷰 동적 필터링 구현
서브메뉴 중 권한이 없는 항목을 자동으로 숨기고 대메뉴 구조를 유지하는 사이드바 뷰 소스 코드입니다.

    <nav id="sidebar">
        <div class="sidebar-header">
            <div class="sidebar-icon-box" id="sidebarToggle" title="메뉴 접기/펼치기" style="cursor: pointer;">
                <i class="fa-solid fa-angles-left"></i>
            </div>
            <div class="sidebar-text-box">
                <a href="{{ route('admin.shops.index') }}" class="sidebar-brand">GLOBEAU ADMIN</a>
            </div>
        </div>

        <div class="sidebar-menu">
            @foreach(config('admin_menu.main_menu') as $index =>$menu)
                @php
                    $user = auth()->user();$isAdmin = $user->hasRole('admin');$hasMenuPermission = true;

                    $filteredChildren = [];
                    if (isset($menu['children'])) {
                        foreach ($menu['children'] as $child) {$hasChildPermission = $isAdmin \vert{}\vert{} (isset($child['permission']) && $user->hasPermissionTo($child['permission']));
                            if ($hasChildPermission) {
                                $filteredChildren[] =$child;
                            }
                        }
                    }

                    $shouldDisplay = false;
                    if (!isset($menu['children'])) {$shouldDisplay = true;
                    } else {
                        $shouldDisplay = (count($filteredChildren) > 0);
                    }
                @endphp

                @if($shouldDisplay)
                    @if(isset($menu['children']))
                        @php
                            $isActive = isset($menu['pattern']) && request()->routeIs($menu['pattern']);
                            $collapseId = 'mainSubmenu' .$index;
                            
                            $hasChildBadge = false;
                            foreach($filteredChildren as$child) {
                                if (isset($child['badge']) && call_user_func($child['badge']) > 0) {$hasChildBadge = true;
                                    break;
                                }
                            }
                        @endphp
                        <div>
                            <a href="#" class="menu-item-row {{ $isActive ? 'active' : '' }}" data-bs-toggle="collapse" data-bs-target="#{{ $collapseId }}" aria-expanded="{{ $isActive ? 'true' : 'false' }}" title="{{ $menu['title'] }}" onclick="event.preventDefault();">
                                <div class="sidebar-icon-box"><i class="{{ $menu['icon'] }}"></i></div>
                                <div class="menu-text-content">
                                    <span class="d-flex align-items-center">
                                        {{ $menu['title'] }}
                                        @if($hasChildBadge)
                                            <span class="ms-2" style="width: 6px; height: 6px; background-color: #ef4444; border-radius: 50%; display: inline-block;"></span>
                                        @endif
                                    </span>
                                    <i class="fa-solid fa-chevron-down submenu-arrow small"></i>
                                </div>
                            </a>
                            <ul class="submenu collapse {{ $isActive ? 'show' : '' }}" id="{{ $collapseId }}">
                                @foreach($filteredChildren as$child)
                                    @php
                                        $isChildActive = isset($child['pattern']) && request()->routeIs($child['pattern']);$childUrl = ($child['route'] !== '#') ? route($child['route']) : '#';
                                        $childBadgeCount = isset($child['badge']) ? call_user_func($child['badge']) : 0;
                                    @endphp
                                    <li class="{{ $child['class'] ?? '' }}">
                                        <a href="{{ $childUrl }}" class="d-flex align-items-center justify-content-between {{ $isChildActive ? 'active' : '' }}">
                                            <span>{{ $child['title'] }}</span>
                                            @if($childBadgeCount > 0)
                                                <span class="badge badge-solid-danger badge-sm rounded-pill ms-2">{{ $childBadgeCount }}</span>
                                            @endif
                                        </a>
                                    </li>
                                @endforeach
                            </ul>
                        </div>
                    @else
                        @php
                            $isActive = isset($menu['pattern']) && request()->routeIs($menu['pattern']);$menuUrl = ($menu['route'] !== '#') ? route($menu['route']) : '#';
                            $badgeCount = isset($menu['badge']) ? call_user_func($menu['badge']) : 0;
                        @endphp
                        <a href="{{ $menuUrl }}" class="menu-item-row {{ $isActive ? 'active' : '' }}" title="{{ $menu['title'] }}">
                            <div class="sidebar-icon-box"><i class="{{ $menu['icon'] }}"></i></div>
                            <div class="menu-text-content">
                                <span>{{ $menu['title'] }}</span>
                                @if($badgeCount > 0)
                                    <span class="badge badge-solid-danger badge-sm rounded-pill ms-auto">{{ $badgeCount }}</span>
                                @endif
                            </div>
                        </a>
                    @endif
                @endif
            @endforeach
        </div>
    </nav>

---

## 4. 백엔드 라우트 미들웨어 적용
관리자 라우트 파일(`routes/admin.php`)에서 Spatie의 `permission` 미들웨어를 활용하여 각 모듈별 접근 권한을 엄격하게 제어합니다.

    Route::middleware(['permission:users.menu'])->prefix('users')->name('users.')->group(function () {
        Route::get('/', [AdminUserController::class, 'index'])->name('index');             
        Route::get('/list', [AdminUserController::class, 'list'])->name('list');                 
        Route::get('/{user}', [AdminUserController::class, 'show'])->name('show');                
        Route::patch('/{user}/status', [AdminUserController::class, 'updateStatus'])->name('status'); 
        Route::get('/{user}/edit', [AdminUserController::class, 'edit'])->name('edit');
        Route::put('/{user}', [AdminUserController::class, 'update'])->name('update');
    });

---

## 5. 컨트롤러 공통 방어 로직 (operator 격리)
operator 계정이 최고관리자(`admin`)의 정보나 다른 operator 계정의 정보에 절대 접근할 수 없도록 컨트롤러 레벨에서 타겟 유저를 검증하는 공통 메서드(`authorizeTargetUser`) 구현 패턴입니다.

    use App\Models\User;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;

    class AdminUserController extends Controller
    {
        /**
         * operator가 타겟 유저에 접근할 자격이 있는지 검사하는 공통 방어 메서드
         */
        private function authorizeTargetUser(User $targetUser)
        {
            $currentUser = Auth::user();

            if ($currentUser->hasRole('admin')) {
                return;
            }

            // operator인 경우: 타겟 유저가 admin이거나 본인 계정이 아닌 경우 즉시 403 차단
            if ($targetUser->hasRole('admin') || $targetUser->id !==$currentUser->id) {
                abort(403, '해당 회원 정보에 접근할 권한이 없습니다.');
            }
        }

        public function show(User $user)
        {
            $this->authorizeTargetUser($user);
            return view('admin.users.show', compact('user'));
        }

        public function edit(User $user)
        {
            $this->authorizeTargetUser($user);
            return view('admin.users.edit', compact('user'));
        }

        public function update(Request $request, User$user)
        {
            $this->authorizeTargetUser($user);
            // 수정 로직 수행...
        }
    }

---

## 6. 보안 핵심 요약
- **단방향 해시(Bcrypt/Argon2):** 비밀번호는 복호화가 불가능한 해시 형태로 저장되어 관리자나 해커가 원문을 알 수 없습니다.
- **다중 방어선:** UI, 라우트 미들웨어, 컨트롤러 내부 검증(`authorize`)을 결합하여 우회 접근을 원천 차단합니다.
- **역할 격리:** operator 계정 간 및 최고관리자와의 데이터 격리를 통해 내부 정보 유출 가능성을 완벽히 방어합니다.