Files
2026-05-11 11:29:41 +09:00

15 KiB

메뉴 시스템

G7의 메뉴 권한 시스템과 메뉴 시더 작성 규칙을 설명합니다.


TL;DR (5초 요약)

1. 구조: User → Role → role_menus 피벗 → Menu
2. 권한: MenuPermissionType (read, write, delete)
3. 모든 메뉴: role_menus에 명시적 역할 권한 필요 (코어/확장 구분 없음)
4. 자동 부여: 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할 자동 부여
5. 시더: Menu::create() + grantAdminRoleToMenus() 패턴

목차

  1. 메뉴 권한 아키텍처
  2. role_menus 피벗 테이블
  3. MenuPermissionType Enum
  4. 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 피벗 테이블

테이블 구조

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

// 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 인터페이스를 구현하여 역할 기반 접근 제어를 지원합니다.

인터페이스 구현

// 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 쿼리 스코프

/**
 * 사용자에게 접근 가능한 메뉴들만 조회하는 스코프
 *
 * 권한 체크 로직:
 * 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

namespace Modules\Sirsoft\Ecommerce\Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Module;
use App\Enums\MenuPermissionType;

class EcommerceMenuSeeder extends Seeder
{
    public function run(): void
    {
        $this->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 사용 (기존 권한 유지)

// 기존 권한을 유지하면서 새 권한 추가
$role->menus()->syncWithoutDetaching([
    $menuId => ['permission_type' => 'read'],
]);

메뉴 권한 확인 방법

1. User 모델에서 확인

// 기본 (read 권한)
$user->hasMenuPermission($menuId);

// 특정 권한 타입
$user->hasMenuPermission($menuId, 'write');
$user->hasMenuPermission($menuId, MenuPermissionType::Delete);

2. 쿼리 스코프로 필터링

// 사용자가 접근 가능한 메뉴 조회
$accessibleMenus = Menu::accessibleBy($user)->get();

// 계층 구조와 함께 조회
$accessibleMenus = Menu::accessibleBy($user)
    ->whereNull('parent_id')
    ->with(['children' => function ($query) use ($user) {
        $query->accessibleBy($user);
    }])
    ->get();

3. Role 모델에서 확인

// 역할에 메뉴 권한 부여
$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 "정적 메뉴/권한 제거 시 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 참조.


관련 문서