# 권한 시스템

> G7의 권한(Permission)과 역할(Role) 시스템

---

## TL;DR (5초 요약)

```text
1. 구조: User → Role → Permission (기능 레벨)
2. 리소스: User → Role → role_menus 피벗 → Menu
3. 네이밍: [module].[entity].[action] (예: users.read)
4. type 필드: admin(관리자용) 또는 user(사용자용), 기본값: admin
5. scope_type: 역할별 접근 스코프 (null=전체, self=본인, role=소유역할)
```

---

## 목차

1. [권한 아키텍처](#권한-아키텍처)
2. [권한 타입 (Permission Type)](#권한-타입-permission-type)
3. [모듈 Role/Permission 자동 관리](#모듈-rolepermission-자동-관리)
4. [Role 정의 규칙](#role-정의-규칙)
5. [Permission에 Role 할당](#permission에-role-할당)
6. [Role 삭제 조건](#role-삭제-조건)
7. [콘솔 커맨드 출력](#콘솔-커맨드-출력)
8. [권한 네이밍](#권한-네이밍)
9. [리소스 레벨 권한](#리소스-레벨-권한)
10. [레이아웃 컴포넌트 권한 필터링](#레이아웃-컴포넌트-권한-필터링)

---

## 권한 아키텍처

G7의 권한 시스템은 두 가지 레벨로 구성됩니다.

### 기능 레벨 권한 (Feature-level)

```text
User → Role → Permission
```

- **User**: 시스템 사용자
- **Role**: 역할 그룹 (여러 Permission을 묶음)
- **Permission**: 개별 권한 (특정 작업 수행 허용)

### 리소스 레벨 권한 (Resource-level)

```text
User → Role → role_menus 피벗 테이블 → Menu
```

- **role_menus**: 메뉴 접근 권한

> **참고**: 첨부파일(Attachment)은 기능 레벨 권한(`core.attachments.*` + `scope_type`)으로 제어합니다. `role_attachments` 독립 시스템은 폐기되었습니다.

---

## 권한 타입 (Permission Type)

권한은 `type` 필드로 사용 컨텍스트를 구분합니다.

### PermissionType Enum

```php
// app/Enums/PermissionType.php

enum PermissionType: string
{
    case Admin = 'admin';  // 관리자 화면용 권한
    case User = 'user';    // 사용자(프론트엔드) 화면용 권한
}
```

### 타입별 용도

| 타입 | 용도 | 예시 |
|------|------|------|
| `admin` | 관리자 화면에서 사용되는 권한 | 사용자 관리, 설정, 모듈 관리 |
| `user` | 사용자(프론트엔드) 화면에서 사용되는 권한 | 내 주문 조회, 프로필 수정 |

### 관리자 판단 기준

```php
// User::isAdmin() 메서드
// 사용자가 보유한 권한 중 type='admin'인 것이 하나라도 있으면 관리자

public function isAdmin(): bool
{
    return $this->roles()
        ->whereHas('permissions', function ($query) {
            $query->where('type', PermissionType::Admin);
        })
        ->exists();
}
```

### 모듈/플러그인에서 권한 타입 지정

```php
// getPermissions() 반환 구조
'permissions' => [
    [
        'action' => 'read',
        'name' => ['ko' => '조회', 'en' => 'Read'],
        'description' => ['ko' => '...', 'en' => '...'],
        'type' => 'admin',  // admin 또는 user (기본값: admin)
        'roles' => ['admin', 'manager'],
    ],
    [
        'action' => 'view_own',
        'name' => ['ko' => '내 정보 조회', 'en' => 'View Own'],
        'type' => 'user',  // 사용자 화면용 권한
        'roles' => ['user'],
    ],
],
```

### 코어 권한에서 타입 지정 (config/core.php)

코어 권한(`config/core.php`의 `categories`)도 모듈/플러그인 매니페스트와 **동일하게 `type` 필드를 지원**합니다. `CoreUpdateService::syncCoreRolesAndPermissions()`가 이 필드를 읽어 admin/user 컨텍스트를 분리합니다.

```php
// config/core.php
'categories' => [
    [
        'identifier' => 'core.users',
        'category' => 'users',
        'order' => 1,
        'type' => 'admin',                          // ← 카테고리 타입 명시
        'permissions' => [
            ['identifier' => 'core.users.read', 'type' => 'admin', ...],   // ← 권한 타입 명시
            ['identifier' => 'core.users.create', 'type' => 'admin', ...],
        ],
    ],
    [
        'identifier' => 'core.user-notifications',
        'category' => 'user-notifications',
        'order' => 7.6,
        'type' => 'user',                           // ← 사용자 컨텍스트 카테고리
        'permissions' => [
            ['identifier' => 'core.user-notifications.read', 'type' => 'user', ...],
        ],
    ],
],
```

```text
✅ 필수: 신규 코어 권한 정의 시 카테고리/개별 권한 모두에 `type` 명시 (가독성 + 의도 명확화)
✅ 카테고리 type은 (1) 명시 필드 (2) 하위 권한이 모두 동일 type일 때 추론 (3) admin 기본값 우선순위로 결정됨
✅ 권한 type 미지정 시 카테고리 type을 상속
```

### CRITICAL: 단일 unique 제약 함정

```text
⚠️ CRITICAL: permissions.identifier 컬럼은 단일 unique 제약입니다.
⚠️ 같은 식별자로 (identifier='core.notifications.read', type='admin')과 (identifier='core.notifications.read', type='user') 두 행을 동시에 생성할 수 없습니다.
```

| 잘못된 접근 | 올바른 접근 |
|---|---|
| `core.notifications.*` 하나의 식별자를 admin/user 양쪽에서 사용 | admin은 `core.notifications.*` (`type=admin`), 사용자는 `core.user-notifications.*` (`type=user`)로 **별도 식별자** 분리 |
| 라우트 미들웨어에서 admin 식별자를 user 컨텍스트에 사용 | 사용자 라우트(`/api/user/...`)는 반드시 `type=user` 권한 행이 존재하는 식별자를 사용 |

`PermissionMiddleware`는 `(identifier, type)` 쌍으로 권한 행을 조회하므로, 라우트 미들웨어 `permission:user,xxx`는 DB에 `(identifier='xxx', type='user')` 행이 존재할 때만 통과합니다.

### 라우트 미들웨어 type 일치 규칙

```php
// routes/api.php

// ❌ 잘못된 예: 사용자 라우트에 admin 타입 권한 식별자 사용
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
    ->middleware('permission:user,core.notifications.read');  // 항상 403 — type 불일치

// ✅ 올바른 예: 사용자 라우트에는 user 타입 권한 식별자 사용
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
    ->middleware('permission:user,core.user-notifications.read');

// ✅ 관리자 라우트에는 admin 타입 권한 식별자 사용
Route::get('/api/admin/notifications', [AdminNotificationController::class, 'index'])
    ->middleware('permission:admin,core.notifications.read');
```

`Broadcast::channel()` 권한 체크에서도 동일하게 `PermissionType` 인자를 명시해야 합니다:

```php
// routes/channels.php
Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) {
    return $user->uuid === $uuid
        && $user->hasPermission('core.user-notifications.read', PermissionType::User);
});
```

### Permission 모델 헬퍼

```php
// 타입 확인 메서드
$permission->isAdminPermission();  // type === 'admin'
$permission->isUserPermission();   // type === 'user'

// 스코프
Permission::adminPermissions()->get();  // admin 타입만
Permission::userPermissions()->get();   // user 타입만
```

---

## 모듈 Role/Permission 자동 관리

```text
중요: 모듈 설치 시 Role과 Permission이 자동으로 생성/연결됨
✅ 필수: getRoles()와 getPermissions()의 roles 필드 사용
```

> **플러그인 권한**: 플러그인도 모듈과 동일한 3레벨 계층 구조(`categories`)를 사용합니다. 상세는 [plugin-development.md](plugin-development.md)의 "플러그인 권한 시스템" 섹션을 참조하세요.

### 동작 방식

#### 모듈 설치 시 (module:install)

1. 마이그레이션 실행
2. `getRoles()`에서 정의한 Role 생성
3. `getPermissions()`에서 정의한 Permission 생성
4. Permission의 `roles` 필드에 지정된 Role에 권한 할당
5. 메뉴 생성
6. 시더 실행

#### 모듈 제거 시 (module:uninstall)

1. Role에서 Permission 분리 (detach)
2. Permission 삭제
3. Role 삭제 (조건: `extension_type`이 NULL(사용자 생성) AND 남은 권한 0개)
4. 메뉴 삭제
5. 마이그레이션 롤백

---

## Role 정의 규칙

### identifier 네이밍

```text
[vendor-module].[rolename]
```

### 예시

```text
sirsoft-ecommerce.manager
sirsoft-ecommerce.viewer
sirsoft-sample.manager
```

### getRoles() 반환 구조

```php
public function getRoles(): array
{
    return [
        [
            'identifier' => 'sirsoft-ecommerce.manager',
            'name' => [
                'ko' => '이커머스 관리자',
                'en' => 'Ecommerce Manager',
            ],
            'description' => [
                'ko' => '이커머스 모듈의 관리 권한을 가진 역할',
                'en' => 'Role with management permissions for ecommerce module',
            ],
        ],
    ];
}
```

**필수 필드**:

| 필드          | 타입   | 설명                                   |
| ------------- | ------ | -------------------------------------- |
| `identifier`  | string | 고유 식별자 (vendor-module.rolename)   |
| `name`        | array  | 다국어 역할명                          |
| `description` | array  | 다국어 설명                            |

---

## Permission에 Role 할당

### getPermissions() roles 필드

```php
public function getPermissions(): array
{
    return [
        [
            'identifier' => 'sirsoft-ecommerce.products.view',
            'name' => ['ko' => '상품 조회', 'en' => 'View Products'],
            'description' => ['ko' => '...', 'en' => '...'],
            'roles' => ['admin', 'sirsoft-ecommerce.manager'],  // Role 지정
        ],
    ];
}
```

### 규칙

- `roles` 필드에 지정된 Role에 자동으로 권한 할당
- 기존 Role의 권한은 `syncWithoutDetaching`으로 보존
- 여러 Role에 동일 권한 할당 가능

### syncWithoutDetaching 동작

```php
// 기존 권한은 유지하면서 새 권한만 추가
$role->permissions()->syncWithoutDetaching($permissionIds);
```

- 모듈 A가 `admin` Role에 권한 할당
- 모듈 B도 `admin` Role에 권한 할당
- 모듈 A 제거 시 모듈 B의 권한은 유지됨

---

## Role 삭제 조건

### 안전한 삭제를 위한 조건

1. `extension_type`이 NULL (사용자 생성 Role만 삭제 가능)
2. `extension_type = 'core'` → 삭제 불가 (코어 역할 보호)
3. `extension_type = 'module'` 또는 `'plugin'` → 삭제 불가 (확장 소유 역할 보호, `ExtensionOwnedRoleDeleteException` 발생)
4. 남은 권한 개수 = 0 (다른 모듈의 권한이 없어야 함)

### 조건을 만족하지 않는 경우

- `extension_type`이 설정된 Role은 `ExtensionOwnedRoleDeleteException` 또는 403 응답
- 남은 권한이 있는 경우 삭제되지 않고 로그에 경고 기록

### Extension Ownership 예시

```php
// 코어 Role (extension_type = 'core', extension_identifier = 'core')
'admin'      // 최고 관리자 — 삭제 불가

// 모듈 Role (extension_type = 'module', extension_identifier = 'sirsoft-ecommerce')
'sirsoft-ecommerce.manager'  // 모듈 소유 — 삭제 불가

// 사용자 생성 Role (extension_type = NULL)
'custom-role'  // 사용자 생성 — 삭제 가능
```

---

## 콘솔 커맨드 출력

### 설치 시

```text
✅ 모듈 "sirsoft-sample" 설치 완료
   - 벤더: sirsoft
   - 버전: 1.0.0
   - 1개 역할 생성됨
   - 4개 권한 생성됨
   - 3개 메뉴 생성됨
```

### 제거 시

```text
모듈 "sirsoft-sample"을(를) 정말 삭제하시겠습니까?
- 1개 역할이 삭제됩니다.
- 4개 권한이 삭제됩니다.
- 3개 메뉴가 삭제됩니다.
```

---

## 권한 네이밍

### 코어 권한

```text
core.[entity].[action]
```

**예시**:

```text
core.templates.layouts.view
core.templates.layouts.create
core.templates.layouts.edit
core.templates.layouts.delete
core.users.view
core.users.manage
```

### 모듈 권한

```text
[vendor-module].[entity].[action]
```

**예시**:

```text
sirsoft-ecommerce.products.view
sirsoft-ecommerce.products.create
sirsoft-ecommerce.products.edit
sirsoft-ecommerce.products.delete
sirsoft-ecommerce.orders.view
sirsoft-ecommerce.orders.manage
```

### 권한 네이밍 규칙 요약

| 구분   | 패턴                            | 예시                                 |
| ------ | ------------------------------- | ------------------------------------ |
| 코어   | `core.[entity].[action]`        | `core.users.view`                    |
| 모듈   | `[vendor-module].[entity].[action]` | `sirsoft-ecommerce.products.create` |

### 일반적인 action 목록

| Action   | 설명                                    |
| -------- | --------------------------------------- |
| `view`   | 조회 권한                               |
| `create` | 생성 권한                               |
| `edit`   | 수정 권한                               |
| `delete` | 삭제 권한                               |
| `manage` | 관리 권한 (view + create + edit + delete) |

---

## 리소스 레벨 권한

G7은 기능 레벨 권한 외에 **리소스 레벨 권한**을 지원합니다. 특정 리소스(메뉴 등)에 대한 세밀한 접근 제어가 가능합니다.

### 적용된 리소스

| 리소스       | 권한 방식 | 설명 |
| ------------ | --------- | ---- |
| Menu         | `role_menus` 피벗 테이블 | 역할별 메뉴 접근 제어 |
| Attachment   | `core.attachments.*` + `scope_type` | 기능 레벨 권한으로 제어 |

> **참고**: 첨부파일은 `role_attachments` 독립 시스템이 아닌, 기존 `permissions` + `scope_type` 시스템으로 통합되었습니다.
> - `core.attachments.create`: 업로드 권한
> - `core.attachments.update`: 수정/순서변경 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
> - `core.attachments.delete`: 삭제 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
> - 공개 다운로드는 `HookManager::checkHookPermission('core.attachment.download')` 으로 제어

---

## API 응답 권한 메타 (is_owner + abilities)

모든 API 리소스 응답에 `is_owner`와 `abilities` (can_*) 필드가 표준화되어 포함됩니다.

### 응답 형식

```json
{
    "id": 1,
    "name": "상품명",
    "is_owner": true,
    "abilities": {
        "can_update": true,
        "can_delete": false
    }
}
```

- `is_owner`: 현재 요청 사용자가 리소스 소유자인지 여부
- `abilities`: `can_*` 키로 통합된 권한 플래그

### 권한 확인 순서 (checkAbility)

```text
1. Admin 역할 → 무조건 true
2. 인증된 사용자 → $user->hasPermission($identifier)
3. 게스트 → PermissionHelper::check($identifier)
```

### 프론트엔드에서 사용

레이아웃 JSON에서 `abilities.can_*` 키로 접근합니다:

```json
{
    "if": "{{item.abilities.can_update}}",
    "type": "Button",
    "props": { "text": "수정" }
}
```

> **상세 구현 규칙**: [api-resources.md](../backend/api-resources.md#권한-메타-표준화-is_owner--abilities) 참조

---

## PermissionHelper — 서비스/컨트롤러에서 권한 체크

`PermissionHelper` 헬퍼 클래스를 사용하여 서비스/컨트롤러 내부에서 권한을 체크합니다.
scope_type 기반 스코프 체크 기능이 내장되어 있습니다.

```php
use App\Helpers\PermissionHelper;

// 기본 권한 체크
PermissionHelper::check('core.users.update');

// 구조화 권한 (OR/AND)
PermissionHelper::checkWithLogic(['or' => ['perm.a', 'perm.b']]);

// scope_type 기반 상세 접근 체크
PermissionHelper::checkScopeAccess($model, 'core.users.update', $user);

// scope_type 기반 목록 필터링 (Repository에서 사용)
PermissionHelper::applyPermissionScope($query, 'core.users.read');

// effective scope 조회
PermissionHelper::getEffectiveScope('core.users.read', $user); // null|'self'|'role'

// 소유자 정보 조회 (캐싱)
PermissionHelper::getOwnerKey('core.users.read');       // 'id'
PermissionHelper::getResourceRouteKey('core.users.read'); // 'user'
```

**메서드 시그니처**:

```php
public static function check(string $ability, ?User $user = null): bool
public static function checkWithLogic(array|object $permissions, ?User $user = null): bool
public static function checkScopeAccess(Model $model, string $permission, ?User $user = null): bool
public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void
public static function getEffectiveScope(string $permission, ?User $user = null): ?string
public static function getOwnerKey(string $permission): ?string
public static function getResourceRouteKey(string $permission): ?string
```

### 권한 체크 방식 선택 가이드

| 상황 | 사용 방법 |
|------|----------|
| 라우트 접근 제어 | `permission:` 미들웨어 (scope_type 자동 체크) |
| 서비스 내부 조건부 권한 체크 | `PermissionHelper::check()` 직접 호출 |
| 상세 엔드포인트 스코프 체크 | `PermissionHelper::checkScopeAccess()` |
| 목록 엔드포인트 필터링 | `PermissionHelper::applyPermissionScope()` (Repository에서 호출) |
| API 응답 abilities 플래그 | `HasAbilityCheck` 트레이트 (`abilityMap()`) |
| 리소스 레벨 접근 제어 (메뉴 등) | `role_*` 피벗 테이블 기반 |
| 레이아웃 컴포넌트 필터링 | `permissions` 속성 (서버 사이드 제거) |

### 주의 사항

```text
❌ AuthServiceProvider::checkPermission() 직접 호출 → PermissionHelper::check() 사용
❌ 서비스 내부에서 미들웨어에 의존한 권한 체크 생략 → PermissionHelper로 명시적 체크
❌ except/only/menu 미들웨어 옵션 사용 (제거됨) → scope_type 데이터 기반 시스템 사용
```

---

## scope_type 스코프 시스템

역할별 접근 범위를 `role_permissions` 피벗의 `scope_type` 컬럼으로 제어합니다.
모든 라우트 미들웨어 옵션 (`except:`, `only:`, `menu:`)은 **제거**되었습니다.

### DB 구조

| 테이블 | 컬럼 | 타입 | 용도 |
|--------|------|------|------|
| `permissions` | `resource_route_key` | VARCHAR(50) NULL | 라우트 파라미터명 (예: 'user', 'menu') |
| `permissions` | `owner_key` | VARCHAR(50) NULL | 모델의 소유자 식별 컬럼 (예: 'id', 'created_by') |
| `role_permissions` | `scope_type` | ENUM('self', 'role') NULL | 역할별 접근 스코프 |

### ScopeType Enum

```php
// app/Enums/ScopeType.php
enum ScopeType: string
{
    case Self = 'self';   // 본인 리소스만
    case Role = 'role';   // 소유 역할 공유 사용자의 리소스
}
// null = 전체 접근 (제한 없음)
```

### scope_type 값 정의

| 값 | 의미 | 목록 필터링 | 상세 접근 체크 |
|---|---|---|---|
| `null` | 전체 접근 | 필터 미적용 | 항상 통과 |
| `'self'` | 본인 리소스만 | `WHERE {owner_key} = {user_id}` | `$model->{owner_key} === $user->id` |
| `'role'` | 내 역할 범위 | `WHERE {owner_key} IN (내 역할 공유 사용자)` | 소유자가 내 역할 공유하는지 체크 |

### union 정책 (복수 역할 보유 시)

- **우선순위**: `null`(전체) > `'role'`(소유역할) > `'self'`(본인)
- 여러 역할 중 하나라도 scope_type=null → 전체 접근
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용

### 미들웨어 처리 흐름

```text
permission:admin,core.users.read  ← 옵션 없음

1. 권한 타입 검증
2. 동적 파라미터 치환
3. 권한 체크 (인증/게스트)
4. scope_type 스코프 체크:
   a. Permission 조회 (캐싱) → resource_route_key, owner_key
   b. resource_route_key null → 통과 (시스템 리소스)
   c. $request->route(resource_route_key) → 모델 resolve
   d. 모델 없음 → 통과 (목록 엔드포인트)
   e. 사용자의 effective scope 확인 (union 정책)
   f. scope=null → 통과
   g. scope='self' → $model->{owner_key} === $user->id
   h. scope='role' → 소유자가 내 역할 공유하는지 체크
```

### 목록 엔드포인트 필터링 (Repository 패턴)

```php
// 모든 Repository에서 한 줄로 적용
$query = User::query();
PermissionHelper::applyPermissionScope($query, 'core.users.read');
// ... 기존 필터/정렬/페이지네이션
```

### 소유자 식별 전체 매핑

| Permission prefix | resource_route_key | owner_key | 비고 |
|---|---|---|---|
| core.users.* | user | id | self: $user->id === $model->id |
| core.menus.* | menu | created_by | |
| core.attachments.update/delete | attachment | created_by | create는 scope 불필요 |
| core.schedules.* | schedule | created_by | |
| core.activity_logs.* | null | null | 소유자 없음 |
| sirsoft-board.boards.* | board | created_by | |
| sirsoft-board.{slug}.*.posts.* | post | user_id | |
| sirsoft-ecommerce.products.* | product | created_by | |
| sirsoft-ecommerce.orders.* | order | user_id | |
| sirsoft-page.pages.* | page | created_by | |

> resource_route_key/owner_key가 null → scope_type UI 미표시, 설정해도 무시

### getPermissions() 포맷 (모듈)

```php
'categories' => [
    [
        'identifier' => 'pages',
        'resource_route_key' => 'page',   // 리소스 라우트 키
        'owner_key' => 'created_by',       // 소유자 컬럼
        'permissions' => [
            [
                'action' => 'read',
                'roles' => ['admin', 'manager'],  // 문자열 → scope_type=null
            ],
            [
                'action' => 'delete',
                'roles' => [
                    ['role' => 'admin', 'scope_type' => null],
                    ['role' => 'manager', 'scope_type' => 'self'],
                ],
            ],
        ],
    ],
],
```

### 역할 관리 UI

- PermissionTree 컴포넌트에 scope_type 드롭다운 추가
- resource_route_key가 있는 권한 + 체크된 상태에서만 드롭다운 표시
- 선택지: 전체(null), 소유역할('role'), 본인만('self')
- 폼 데이터: `permissions: [{id: 1, scope_type: null}, {id: 2, scope_type: 'self'}]`

---

## 레이아웃 컴포넌트 권한 필터링

레이아웃 JSON의 컴포넌트에 `permissions` 속성을 정의하면, **레이아웃 서빙 API에서 해당 컴포넌트를 제거하여 서빙**합니다. 프론트엔드 `if` 조건부 렌더링과 달리 JSON 구조 자체가 클라이언트에 노출되지 않아 보안이 강화됩니다.

### 동작 원리

```text
파이프라인 위치:
[캐시 조회] → [extends/partial 병합] → [applyExtensions] → [★ filterComponentsByPermissions] → [훅] → [응답]
```

- **Post-cache filtering**: 캐시 이후 사용자별 동적 필터링 (캐시 구조 영향 없음, ~1-2ms 추가)
- **AND 조건**: `permissions` 배열의 모든 권한을 충족해야 컴포넌트 포함
- **Admin 바이패스**: `admin` 역할은 `Gate::before()`에 의해 모든 권한 통과

### 필터링 대상 영역

| 영역 | 설명 |
|------|------|
| `components` | 메인 컴포넌트 트리 (재귀 children 포함) |
| `modals[]` | 모달 자체에 permissions → 모달 항목 전체 제거 |
| `modals[].components` | 모달 내부 컴포넌트 트리 (재귀) |
| `defines` | 재사용 컴포넌트 정의 (재귀) |
| partial 내부 | extends/partial 병합 후 필터링이므로 자동 처리 |

### 필터링 규칙

1. 현재 노드의 `permissions` 배열 확인 (없거나 빈 배열이면 통과)
2. `AuthServiceProvider::checkPermissions($permissions, $user)` 호출 (AND 조건)
3. 권한 없으면 해당 노드 및 하위 children **전체 제거** (하위 평가 생략)
4. 권한 있으면 통과 → children을 재귀적으로 독립 평가
5. 필터링 완료 후 모든 노드에서 `permissions` 속성 제거 (클라이언트 노출 방지)

### 상위-하위 중복 선언

```json
{
  "type": "Div",
  "permissions": ["core.users.read"],
  "children": [
    { "type": "Button", "permissions": ["core.users.delete"], "text": "삭제" }
  ]
}
```

| 시나리오 | 상위 결과 | 하위 결과 | 최종 |
|---------|---------|---------|------|
| `read` 없음 | 상위 제거 → 전체 제거 | 평가 안 됨 | 둘 다 없음 |
| `read` 있음 + `delete` 없음 | 통과 | 하위만 제거 | Div만 남음 |
| `read` 있음 + `delete` 있음 | 통과 | 통과 | 둘 다 남음 |

→ 각 노드는 자기 permissions만 독립 평가. 별도 병합 정책 없음.

### 확장(Extension)과의 관계

- Extension Point로 주입된 컴포넌트에 `permissions` → 정상 필터링
- Overlay로 교체된 컴포넌트에 `permissions` → 교체된 컴포넌트의 permissions 적용
- 파이프라인 순서: `applyExtensions` → `filterComponentsByPermissions` (확장 적용 후 필터링)

### 레이아웃 최상위 permissions와의 비교

| 항목 | 최상위 `permissions` | 컴포넌트 `permissions` |
|------|---------------------|----------------------|
| 적용 범위 | 레이아웃 전체 | 개별 컴포넌트 단위 |
| 미충족 시 | 403 응답 | 해당 컴포넌트만 제거 |
| 조건 방식 | AND | AND |
| 처리 시점 | 캐시 조회 전 | 캐시 조회 후 (post-cache) |

### 관련 코드

| 파일 | 역할 |
|------|------|
| `app/Services/LayoutService.php` | `filterComponentsByPermissions()` 메서드 |
| `app/Http/Controllers/Api/Public/PublicLayoutController.php` | 서빙 시 필터링 호출 |
| `app/Rules/ValidLayoutStructure.php` | 컴포넌트 `permissions` 검증 |

> **레이아웃 JSON 문법 상세**: [layout-json.md](../frontend/layout-json.md#컴포넌트별-권한-component-permissions) 참조

---

## Model 권한/역할 체크 메서드

User 모델은 권한/역할 체크를 위한 헬퍼 메서드를 제공합니다.

### 메서드 시그니처

```php
// 단일 권한 체크
$user->hasPermission('core.users.read');
$user->hasPermission('core.users.read', PermissionType::Admin);

// 복수 권한 체크
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: true);  // AND
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: false); // OR

// 역할 체크
$user->hasRole('admin');
$user->hasRoles(['admin', 'manager'], requireAll: false); // OR

// 관리자 판단 (admin 타입 권한 보유 여부)
$user->isAdmin();
```

### 인스턴스 레벨 캐싱

`getEffectiveScopeForPermission()` 메서드는 동일 요청 내 반복 호출 시 DB 쿼리를 방지하기 위해 인스턴스 레벨 캐싱을 사용합니다:

```php
// User 모델 내부
protected array $effectiveScopeCache = [];

public function getEffectiveScopeForPermission(string $identifier): ?string
{
    if (array_key_exists($identifier, $this->effectiveScopeCache)) {
        return $this->effectiveScopeCache[$identifier];
    }
    // ... DB 쿼리 및 union 정책 적용 ...
    return $this->effectiveScopeCache[$identifier] = $result;
}
```

---

## Permission 동기화 및 추적

### Role-Permission 동기화 패턴

역할에 권한을 동기화할 때 이전/이후 상태를 추적합니다:

```php
public function syncPermissions(Role $role, array $permissions): void
{
    // 동기화 전 권한 식별자 캡처 (Listener diff 계산용)
    $previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();

    // 피벗 데이터에 granted_at, granted_by 자동 설정
    $pivotData = [];
    foreach ($permissions as $permission) {
        $pivotData[$permission['id']] = [
            'scope_type' => $permission['scope_type'] ?? null,
            'granted_at' => now(),
            'granted_by' => Auth::id(),
        ];
    }

    $role->permissions()->sync($pivotData);

    // 동기화 후 권한 식별자 (Listener에서 diff 계산 가능)
    $currentPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();

    HookManager::doAction('core.role.after_sync_permissions',
        $role, $previousPermIdentifiers, $currentPermIdentifiers);
}
```

### 자기잠금 방지

마지막 admin 역할 보유자가 자신의 admin 역할을 제거하는 것을 방지합니다:

```text
- 관리자가 자기 자신의 역할을 변경하려 하면 → 역할 변경 무시
- 마지막 admin이 admin 역할을 삭제하려 하면 → 거부
```

---

## 비회원(Guest) 권한

### Guest 권한 체크 흐름

```text
비회원 요청 → PermissionMiddleware
  → Guest 역할 조회 (Role::where('identifier', 'guest'))
  → 정적 캐시: self::$guestRoleCache (요청 내 재사용)
  → 권한 매핑 없음 → 기본 허용
  → 권한 매핑 있음 → guest 역할이 해당 권한 보유하는지 확인
```

### 핵심 규칙

| 상황 | 동작 |
| ------ | ------ |
| 권한 미매핑 (permission_hooks 없음) | 모든 사용자(guest 포함) 허용 |
| 권한 매핑 + guest 역할 보유 | 허용 |
| 권한 매핑 + guest 역할 미보유 | 거부 (401/403) |

---

## Role identifier 자동 생성

관리자가 역할 생성 시 `identifier`를 직접 입력하지 않습니다. `RoleService`가 이름에서 자동 생성합니다:

```text
generateIdentifier(name)
    ↓ 배열인 경우 → 영어 이름 우선 (en → ko → 첫 번째 값)
    ↓ Str::slug($baseName, '_') → 슬러그 변환 (예: "Content Manager" → "content_manager")
    ↓ 중복 체크 → 중복 시 _1, _2, ... suffix 자동 추가
    ↓ 반환: 고유한 identifier
```

**예시**:

| 입력 이름 | 생성 identifier |
| ---------- | ---------- |
| `Content Manager` | `content_manager` |
| `Content Manager` (중복) | `content_manager_1` |
| `['ko' => '편집자', 'en' => 'Editor']` | `editor` |

---

## 완전 동기화 (Stale Cleanup)

코어/확장 업그레이드 시 권한·역할 동기화는 단순 upsert 가 아니라 **완전 동기화** 를 수행합니다. config/manifest 에서 제거된 권한·역할은 DB 에서도 삭제됩니다.

### 적용 지점

| 경로 | 호출 시점 | cleanup 대상 |
|---|---|---|
| `CoreUpdateService::syncCoreRolesAndPermissions` 말미 | 코어 업데이트 | `core/core` 소유 권한·역할 |
| `ModuleManager::updateModule` 내 sync 블록 말미 | 모듈 업데이트 | 해당 모듈 소유 권한·역할 |
| `PluginManager::updatePlugin` 내 sync 블록 말미 | 플러그인 업데이트 | 해당 플러그인 소유 권한·역할 |

### 핵심 정책

- **권한 stale 삭제**: 순수 diff — config 에 없으면 즉시 삭제 (user_overrides 개념 없음)
- **역할 stale 삭제**: config 에 없으면 삭제. 단 **`user_roles` 피벗에 사용자가 참조 중인 역할은 삭제 차단 + 경고 로그** (수동 재배정 유도)
- **역할-권한 매핑**: `syncAllRoleAssignments` 가 `core/core` 전체 권한을 diff 기준으로 사용하여 이관된 구 식별자도 detach

### Helper (권장)

직접 Model 조작 금지. `ExtensionRoleSyncHelper` 를 통해 sync/cleanup 수행. 자세한 사용법은 [data-sync-helpers.md](../backend/data-sync-helpers.md#4-extensionrolesynchelper) 참조.

---

## 관련 문서

- [menus.md](./menus.md) - 메뉴 권한 시스템
- [hooks.md](./hooks.md) - 훅 권한 시스템
- [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) - 사용자 수정 보존
