Files
Gnuboard7/docs/extension/permissions.md
T
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00

32 KiB

권한 시스템

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


TL;DR (5초 요약)

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)
  3. 모듈 Role/Permission 자동 관리
  4. Role 정의 규칙
  5. Permission에 Role 할당
  6. Role 삭제 조건
  7. 콘솔 커맨드 출력
  8. 권한 네이밍
  9. 리소스 레벨 권한
  10. 레이아웃 컴포넌트 권한 필터링

권한 아키텍처

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

기능 레벨 권한 (Feature-level)

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

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

User → Role → role_menus 피벗 테이블 → Menu
  • role_menus: 메뉴 접근 권한

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


권한 타입 (Permission Type)

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

PermissionType Enum

// app/Enums/PermissionType.php

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

타입별 용도

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

관리자 판단 기준

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

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

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

// 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 컨텍스트를 분리합니다.

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

CRITICAL: 단일 unique 제약 함정

⚠️ 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 일치 규칙

// 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 인자를 명시해야 합니다:

// 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 모델 헬퍼

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

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

모듈 Role/Permission 자동 관리

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

플러그인 권한: 플러그인도 모듈과 동일한 3레벨 계층 구조(categories)를 사용합니다. 상세는 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 네이밍

[vendor-module].[rolename]

예시

sirsoft-ecommerce.manager
sirsoft-ecommerce.viewer
sirsoft-sample.manager

getRoles() 반환 구조

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 필드

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 동작

// 기존 권한은 유지하면서 새 권한만 추가
$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 예시

// 코어 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'  // 사용자 생성 — 삭제 가능

콘솔 커맨드 출력

설치 시

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

제거 시

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

권한 네이밍

코어 권한

core.[entity].[action]

예시:

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

[entity] 가 특정 확장 타입이 아니라 extensions 인 권한은 템플릿·모듈·플러그인 세 타입이 공유한다. 타입별로 쪼개면 운영자가 같은 성격의 권한을 셋 다 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는" 상태가 실질적 의미 없이 생긴다.

core.extensions.custom_assets.manage

이 권한은 그룹 core.extensions(확장 공통) 에 속한다. 레이아웃 편집 권한 (core.templates.layouts.edit) 과 분리한 이유는 여기서 올린 스크립트가 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되기 때문이다 — 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다.

모듈 권한

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

예시:

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_*) 필드가 표준화되어 포함됩니다.

응답 형식

{
    "id": 1,
    "name": "상품명",
    "is_owner": true,
    "abilities": {
        "can_update": true,
        "can_delete": false
    }
}
  • is_owner: 현재 요청 사용자가 리소스 소유자인지 여부
  • abilities: can_* 키로 통합된 권한 플래그

권한 확인 순서 (checkAbility)

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

프론트엔드에서 사용

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

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

상세 구현 규칙: api-resources.md 참조


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

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

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'

메서드 시그니처:

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 속성 (서버 사이드 제거)

주의 사항

❌ 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

// 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 적용

미들웨어 처리 흐름

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 패턴)

// 모든 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() 포맷 (모듈)

'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 구조 자체가 클라이언트에 노출되지 않아 보안이 강화됩니다.

동작 원리

파이프라인 위치:
[캐시 조회] → [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 속성 제거 (클라이언트 노출 방지)

상위-하위 중복 선언

{
  "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 참조


Model 권한/역할 체크 메서드

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

메서드 시그니처

// 단일 권한 체크
$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 쿼리를 방지하기 위해 인스턴스 레벨 캐싱을 사용합니다:

// 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 동기화 패턴

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

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 역할을 제거하는 것을 방지합니다:

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

비회원(Guest) 권한

Guest 권한 체크 흐름

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

핵심 규칙

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

Role identifier 자동 생성

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

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 참조.


관련 문서