15 KiB
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() 패턴
목차
- 메뉴 권한 아키텍처
- role_menus 피벗 테이블
- MenuPermissionType Enum
- Menu 모델의 HasRoleBasedAccess 구현
- 메뉴 시더에서 권한 부여
- 메뉴 권한 확인 방법
- 권한 체계 요약
메뉴 권한 아키텍처
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 참조.
관련 문서
- permissions.md - 권한 시스템 (Role, Permission, 리소스 레벨 권한)
- module-basics.md - 모듈 기본 구조
- index.md - 확장 시스템 인덱스
- ../backend/core-config.md - 완전 동기화 원칙
- ../backend/data-sync-helpers.md - 데이터 동기화 Helper
- ../backend/user-overrides.md - 사용자 수정 보존