921 lines
31 KiB
Markdown
921 lines
31 KiB
Markdown
# 권한 시스템
|
|
|
|
> 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) - 사용자 수정 보존
|