# 메뉴 시스템 > G7의 메뉴 권한 시스템과 메뉴 시더 작성 규칙을 설명합니다. --- ## TL;DR (5초 요약) ```text 1. 구조: User → Role → role_menus 피벗 → Menu 2. 권한: MenuPermissionType (read, write, delete) 3. 모든 메뉴: role_menus에 명시적 역할 권한 필요 (코어/확장 구분 없음) 4. 자동 부여: 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할 자동 부여 5. 시더: Menu::create() + grantAdminRoleToMenus() 패턴 ``` --- ## 목차 1. [메뉴 권한 아키텍처](#메뉴-권한-아키텍처) 2. [role_menus 피벗 테이블](#role_menus-피벗-테이블) 3. [MenuPermissionType Enum](#menupermissiontype-enum) 4. [Menu 모델의 HasRoleBasedAccess 구현](#menu-모델의-hasrolebasedaccess-구현) 5. [메뉴 시더에서 권한 부여](#메뉴-시더에서-권한-부여) 6. [메뉴 권한 확인 방법](#메뉴-권한-확인-방법) 7. [권한 체계 요약](#권한-체계-요약) --- ## 메뉴 권한 아키텍처 G7에서 메뉴 권한은 **역할 기반(Role-based)**으로 관리됩니다. ``` User → Role → role_menus → Menu (pivot) ``` ### 코어 메뉴 vs 모듈 메뉴 | 메뉴 유형 | 식별 | 기본 정책 | |----------|------|----------| | 코어 메뉴 | `extension_type = 'core'` | role_menus에 명시적 역할 권한 필요 | | 모듈 메뉴 | `extension_type = 'module'` | 확장 활성 상태 + 명시적 역할 권한 필요 | | 플러그인 메뉴 | `extension_type = 'plugin'` | 확장 활성 상태 + 명시적 역할 권한 필요 | | 사용자 생성 메뉴 | `extension_type = NULL` | role_menus에 명시적 역할 권한 필요 | --- ## role_menus 피벗 테이블 ### 테이블 구조 ```php Schema::create('role_menus', function (Blueprint $table) { $table->id(); $table->foreignId('role_id')->constrained()->cascadeOnDelete(); $table->foreignId('menu_id')->constrained()->cascadeOnDelete(); $table->enum('permission_type', ['read', 'write', 'delete'])->default('read'); $table->timestamps(); $table->unique(['role_id', 'menu_id', 'permission_type'], 'uk_role_menu_permission'); }); ``` ### 설계 원칙 - **레코드 존재 = 권한 허용**: `is_allowed` 컬럼 없이 레코드 존재 여부로 판단 - **복합 유니크 인덱스**: 동일 역할-메뉴에 여러 권한 타입 부여 가능 - **Cascade Delete**: 역할 또는 메뉴 삭제 시 자동 정리 --- ## MenuPermissionType Enum ```php // app/Enums/MenuPermissionType.php enum MenuPermissionType: string { case Read = 'read'; case Write = 'write'; case Delete = 'delete'; public function label(): string { return match ($this) { self::Read => '읽기/접근', self::Write => '수정', self::Delete => '삭제', }; } } ``` ### 권한 타입 설명 | 타입 | 값 | 설명 | |------|-----|------| | Read | `read` | 메뉴 조회/접근 권한 | | Write | `write` | 메뉴 관련 데이터 수정 권한 | | Delete | `delete` | 메뉴 관련 데이터 삭제 권한 | --- ## Menu 모델의 HasRoleBasedAccess 구현 `Menu` 모델은 `HasRoleBasedAccess` 인터페이스를 구현하여 역할 기반 접근 제어를 지원합니다. ### 인터페이스 구현 ```php // app/Models/Menu.php class Menu extends Model implements HasRoleBasedAccess { /** * 접근 가능한 역할들 (권한 타입 포함) */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class, 'role_menus') ->withPivot('permission_type') ->withTimestamps(); } /** * 특정 권한 타입을 가진 역할들 조회 */ public function rolesWithPermission(BackedEnum|string $permissionType): BelongsToMany { $type = $permissionType instanceof BackedEnum ? $permissionType->value : $permissionType; return $this->belongsToMany(Role::class, 'role_menus') ->withPivot('permission_type') ->withTimestamps() ->wherePivot('permission_type', $type); } /** * 역할 권한이 설정되어 있는지 확인 */ public function hasRolePermissions(): bool { return $this->roles()->exists(); } /** * 리소스 소유자의 ID 반환 */ public function getOwnerId(): ?int { return $this->created_by; } } ``` ### scopeAccessibleBy 쿼리 스코프 ```php /** * 사용자에게 접근 가능한 메뉴들만 조회하는 스코프 * * 권한 체크 로직: * 1. 관리자는 모든 메뉴 접근 가능 * 2. 코어/사용자 생성 메뉴: role_menus에 명시적 역할 권한 필요 * 3. 모듈/플러그인 메뉴: 확장이 활성화되어 있고 역할 권한이 있어야 접근 가능 * * 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할이 자동 부여되므로 * role_menus 레코드가 없는 메뉴는 누구에게도 보이지 않습니다. */ public function scopeAccessibleBy(Builder $query, User $user): Builder { if ($user->hasRole('admin')) { return $query; } $userRoleIds = $user->roles()->pluck('roles.id')->toArray(); return $query->where(function (Builder $query) use ($userRoleIds) { // 코어/사용자 생성 메뉴: 명시적 역할 권한 필요 $query->where(function (Builder $q) use ($userRoleIds) { $q->where(function ($q2) { $q2->whereNull('extension_type') ->orWhere('extension_type', 'core'); }) ->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) { $roleQ->whereIn('roles.id', $userRoleIds) ->where('role_menus.permission_type', MenuPermissionType::Read->value); }); }); })->orWhere(function (Builder $query) use ($userRoleIds) { // 모듈/플러그인 메뉴 (확장이 활성화되어 있고 역할 권한이 있는 경우) $query->whereIn('extension_type', ['module', 'plugin']) ->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) { $roleQ->whereIn('roles.id', $userRoleIds) ->where('role_menus.permission_type', MenuPermissionType::Read->value); }); }); } ``` --- ## 메뉴 시더에서 권한 부여 ### 예시: EcommerceMenuSeeder ```php command->info('이커머스 메뉴 생성을 시작합니다.'); $module = Module::where('identifier', 'sirsoft-ecommerce')->first(); // 메뉴 생성 $ecommerceMenu = Menu::create([ 'name' => ['ko' => '이커머스', 'en' => 'Ecommerce'], 'slug' => 'sirsoft-ecommerce', 'url' => '/admin/sirsoft-ecommerce', 'icon' => 'fa-shopping-cart', 'module_id' => $module->id, ]); // 권한 부여 $this->grantMenuPermissions($module->id); $this->command->info('이커머스 메뉴가 생성되었습니다.'); } /** * 역할에 메뉴 권한 부여 */ private function grantMenuPermissions(int $moduleId): void { $adminRole = Role::where('identifier', 'admin')->first(); $managerRole = Role::where('identifier', 'sirsoft-ecommerce.manager')->first(); $ecommerceMenus = Menu::where('module_id', $moduleId)->get(); foreach ($ecommerceMenus as $menu) { // 관리자: 모든 권한 $adminRole->menus()->attach($menu->id, [ 'permission_type' => MenuPermissionType::Read->value, ]); $adminRole->menus()->attach($menu->id, [ 'permission_type' => MenuPermissionType::Write->value, ]); $adminRole->menus()->attach($menu->id, [ 'permission_type' => MenuPermissionType::Delete->value, ]); // 매니저: 읽기/쓰기 권한만 if ($managerRole) { $managerRole->menus()->attach($menu->id, [ 'permission_type' => MenuPermissionType::Read->value, ]); $managerRole->menus()->attach($menu->id, [ 'permission_type' => MenuPermissionType::Write->value, ]); } } $this->command->info("역할에 {$ecommerceMenus->count()}개의 메뉴 권한을 부여했습니다."); } } ``` ### syncWithoutDetaching 사용 (기존 권한 유지) ```php // 기존 권한을 유지하면서 새 권한 추가 $role->menus()->syncWithoutDetaching([ $menuId => ['permission_type' => 'read'], ]); ``` --- ## 메뉴 권한 확인 방법 ### 1. User 모델에서 확인 ```php // 기본 (read 권한) $user->hasMenuPermission($menuId); // 특정 권한 타입 $user->hasMenuPermission($menuId, 'write'); $user->hasMenuPermission($menuId, MenuPermissionType::Delete); ``` ### 2. 쿼리 스코프로 필터링 ```php // 사용자가 접근 가능한 메뉴 조회 $accessibleMenus = Menu::accessibleBy($user)->get(); // 계층 구조와 함께 조회 $accessibleMenus = Menu::accessibleBy($user) ->whereNull('parent_id') ->with(['children' => function ($query) use ($user) { $query->accessibleBy($user); }]) ->get(); ``` ### 3. Role 모델에서 확인 ```php // 역할에 메뉴 권한 부여 $role->menus()->attach($menuId, ['permission_type' => 'read']); // 역할의 모든 메뉴 조회 $role->menus()->get(); // 특정 권한 타입의 메뉴만 조회 $role->menus()->wherePivot('permission_type', 'write')->get(); ``` --- ## 권한 체계 요약 ### 권한 체크 흐름 ``` ┌─────────────────────────────────────────────────────────────┐ │ 1. 관리자(admin) 역할 확인 │ │ → admin이면 모든 메뉴 접근 허용 │ ├─────────────────────────────────────────────────────────────┤ │ 2. 코어/사용자 생성 메뉴 (extension_type = 'core' 또는 NULL)│ │ → role_menus에 명시적 역할 권한 필요 │ │ → 메뉴 생성 시 관리자+생성자 역할 자동 부여 │ ├─────────────────────────────────────────────────────────────┤ │ 3. 모듈/플러그인 메뉴 (extension_type = 'module'/'plugin') │ │ → 확장이 활성화(active) 상태여야 함 │ │ → role_menus에 명시적 권한 필요 │ └─────────────────────────────────────────────────────────────┘ ``` ### 권한 타입별 정리 | 권한 타입 | 용도 | 예시 | |----------|------|------| | `read` | 메뉴 표시, 페이지 접근 | 네비게이션 표시 | | `write` | 데이터 생성/수정 | 상품 등록, 수정 | | `delete` | 데이터 삭제 | 상품 삭제 | ### 동적 메뉴 보존 모듈/플러그인이 런타임에 동적으로 생성한 메뉴(예: 게시판 모듈의 개별 게시판 메뉴)는 확장 업데이트 시 자동으로 보존됩니다. - `cleanupStaleMenus()`/`cleanupStalePermissions()`의 **자동 호출은 폐기**되었습니다 - 정적 메뉴/권한의 추가·수정은 업데이트 시 자동 동기화됩니다 - 정적 메뉴/권한을 **제거**해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출하세요 - 상세: [extension-update-system.md](./extension-update-system.md) "정적 메뉴/권한 제거 시 cleanup 사용 예시" 참조 ### Deprecated 항목 다음 항목들은 더 이상 사용하지 않습니다: | 항목 | 대체 방법 | |------|----------| | `menu_permissions` 테이블 | `role_menus` 피벗 테이블 | | `MenuPermission` 모델 | `Role::menus()` 관계 | | `is_allowed` 컬럼 | 레코드 존재 여부로 판단 | | `User::menuPermissions()` | `User::hasMenuPermission()` | | `module_id` 컬럼 | `extension_type` + `extension_identifier` | | `module_identifier` 컬럼 | `extension_identifier` | --- ## 완전 동기화 (Stale Cleanup) 코어/확장 업그레이드 시 메뉴 동기화는 **완전 동기화** — config/manifest 에서 제거된 메뉴는 DB 에서도 삭제됩니다. 유지되는 메뉴는 사용자 수정(`user_overrides` 컬럼) 에 따라 필드 단위 보존이 적용됩니다. ### 적용 지점 | 경로 | 호출 시점 | cleanup 대상 | |---|---|---| | `CoreUpdateService::syncCoreMenus` 말미 | 코어 업데이트 | `core/core` 소유 메뉴 전체 slug | | `ModuleManager::updateModule` 내 sync 블록 말미 | 모듈 업데이트 | 해당 모듈 소유 메뉴 | ### 핵심 정책 - **row 존재 여부**: config 기준 (user_overrides 무관 삭제) - **필드 값 (유지 row)**: user_overrides 에 등록된 필드 (`name`, `icon`, `order`, `url`, `is_active`) 보존, 나머지 갱신 - **`is_active`**: config/manifest 정의값 채택 (기본 `true`). `is_active: false` 로 명시 정의된 메뉴는 sync 시에도 비활성 상태 유지. 운영자가 UI 에서 토글한 경우 `user_overrides` 에 `'is_active'` 가 마킹되어 재시드 시 운영자 선택 보존 - **role-menu 매핑**: 메뉴 삭제 시 `role_menus` 피벗은 FK cascade 로 자동 정리 - **user_overrides 검사**: 다국어 컬럼(`name`)은 column-level (`'name'`, legacy) + dot-path sub-key (`'name.ko'` / `'name.en'`, beta.4 도입) 양쪽 호환. 어느 형태로든 마킹되어 있으면 컬럼 전체를 보존 대상으로 간주 ### 사용자 수정 기록 `Menu` 모델은 `HasUserOverrides` trait + `$trackableFields = ['name', 'icon', 'order', 'url']` 적용. 사용자가 UI/API 로 수정하면 trait 이 자동으로 user_overrides 에 누적 기록합니다 (mass update 포함). ### Helper (권장) 직접 Model 조작 금지. `ExtensionMenuSyncHelper` 를 통해 sync/cleanup 수행. 자세한 사용법은 [data-sync-helpers.md](../backend/data-sync-helpers.md#3-extensionmenusynchelper) 참조. --- ## 관련 문서 - [permissions.md](./permissions.md) - 권한 시스템 (Role, Permission, 리소스 레벨 권한) - [module-basics.md](./module-basics.md) - 모듈 기본 구조 - [index.md](./index.md) - 확장 시스템 인덱스 - [../backend/core-config.md](../backend/core-config.md#완전-동기화-원칙) - 완전 동기화 원칙 - [../backend/data-sync-helpers.md](../backend/data-sync-helpers.md) - 데이터 동기화 Helper - [../backend/user-overrides.md](../backend/user-overrides.md) - 사용자 수정 보존