From 1dd9057540738d6c8ae582490e3c97b8efaabc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 20 Feb 2026 21:59:26 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20[authz]=20=EC=97=AD=ED=95=A0/?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20API=20=ED=92=88=EC=A7=88=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validator::make를 FormRequest로 분리 (6개 생성) - 하드코딩 한글 문자열을 i18n 키로 교체 - RoleMenuPermission 데드코드 제거 - Role 모델 SpatieRole 상속으로 일원화 - 권한 변경 후 캐시 무효화 추가 (AccessService::bumpVersion) - 미문서화 8개 Swagger 엔드포인트 추가 - 역할/권한 라우트에 perm.map+permission 미들웨어 추가 --- .../Controllers/Api/V1/RoleController.php | 16 +- .../Api/V1/RolePermissionController.php | 29 +- .../Controllers/Api/V1/UserRoleController.php | 22 +- app/Http/Requests/Authz/RoleIndexRequest.php | 23 + .../Authz/RolePermissionGrantRequest.php | 38 + .../Authz/RolePermissionToggleRequest.php | 24 + app/Http/Requests/Authz/RoleStoreRequest.php | 31 + app/Http/Requests/Authz/RoleUpdateRequest.php | 32 + .../Requests/Authz/UserRoleGrantRequest.php | 33 + app/Models/Permissions/Role.php | 25 +- app/Models/Permissions/RoleMenuPermission.php | 27 - app/Services/Authz/RolePermissionService.php | 121 +-- app/Services/Authz/RoleService.php | 45 +- app/Services/Authz/UserRoleService.php | 80 +- app/Swagger/v1/RoleApi.php | 83 +- app/Swagger/v1/RolePermissionApi.php | 198 ++++- config/permission.php | 2 +- lang/en/error.php | 10 + lang/ko/error.php | 10 + routes/api/v1/common.php | 6 +- storage/api-docs/api-docs-v1.json | 816 +++++++++++++++++- 21 files changed, 1400 insertions(+), 271 deletions(-) create mode 100644 app/Http/Requests/Authz/RoleIndexRequest.php create mode 100644 app/Http/Requests/Authz/RolePermissionGrantRequest.php create mode 100644 app/Http/Requests/Authz/RolePermissionToggleRequest.php create mode 100644 app/Http/Requests/Authz/RoleStoreRequest.php create mode 100644 app/Http/Requests/Authz/RoleUpdateRequest.php create mode 100644 app/Http/Requests/Authz/UserRoleGrantRequest.php delete mode 100644 app/Models/Permissions/RoleMenuPermission.php diff --git a/app/Http/Controllers/Api/V1/RoleController.php b/app/Http/Controllers/Api/V1/RoleController.php index 789a159..7f5b056 100644 --- a/app/Http/Controllers/Api/V1/RoleController.php +++ b/app/Http/Controllers/Api/V1/RoleController.php @@ -4,28 +4,30 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Authz\RoleIndexRequest; +use App\Http\Requests\Authz\RoleStoreRequest; +use App\Http\Requests\Authz\RoleUpdateRequest; use App\Services\Authz\RoleService; -use Illuminate\Http\Request; class RoleController extends Controller { /** * 역할 목록 조회 */ - public function index(Request $request) + public function index(RoleIndexRequest $request) { return ApiResponse::handle(function () use ($request) { - return RoleService::index($request->all()); + return RoleService::index($request->validated()); }, __('message.fetched')); } /** * 역할 생성 */ - public function store(Request $request) + public function store(RoleStoreRequest $request) { return ApiResponse::handle(function () use ($request) { - return RoleService::store($request->all()); + return RoleService::store($request->validated()); }, __('message.created')); } @@ -42,10 +44,10 @@ public function show($id) /** * 역할 수정 */ - public function update(Request $request, $id) + public function update(RoleUpdateRequest $request, $id) { return ApiResponse::handle(function () use ($request, $id) { - return RoleService::update((int) $id, $request->all()); + return RoleService::update((int) $id, $request->validated()); }, __('message.updated')); } diff --git a/app/Http/Controllers/Api/V1/RolePermissionController.php b/app/Http/Controllers/Api/V1/RolePermissionController.php index 1e5c2c3..ee20ce0 100644 --- a/app/Http/Controllers/Api/V1/RolePermissionController.php +++ b/app/Http/Controllers/Api/V1/RolePermissionController.php @@ -4,37 +4,38 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Authz\RolePermissionGrantRequest; +use App\Http\Requests\Authz\RolePermissionToggleRequest; use App\Services\Authz\RolePermissionService; -use Illuminate\Http\Request; class RolePermissionController extends Controller { - public function index($id, Request $request) + public function index($id) { return ApiResponse::handle(function () use ($id) { return RolePermissionService::list((int) $id); - }, '역할 퍼미션 목록 조회'); + }, __('message.fetched')); } - public function grant($id, Request $request) + public function grant($id, RolePermissionGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return RolePermissionService::grant((int) $id, $request->all()); - }, '역할 퍼미션 부여'); + return RolePermissionService::grant((int) $id, $request->validated()); + }, __('message.updated')); } - public function revoke($id, Request $request) + public function revoke($id, RolePermissionGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return RolePermissionService::revoke((int) $id, $request->all()); - }, '역할 퍼미션 회수'); + return RolePermissionService::revoke((int) $id, $request->validated()); + }, __('message.updated')); } - public function sync($id, Request $request) + public function sync($id, RolePermissionGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return RolePermissionService::sync((int) $id, $request->all()); - }, '역할 퍼미션 동기화'); + return RolePermissionService::sync((int) $id, $request->validated()); + }, __('message.updated')); } /** @@ -60,10 +61,10 @@ public function matrix($id) /** * 특정 메뉴의 특정 권한 토글 */ - public function toggle($id, Request $request) + public function toggle($id, RolePermissionToggleRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return RolePermissionService::toggle((int) $id, $request->all()); + return RolePermissionService::toggle((int) $id, $request->validated()); }, __('message.updated')); } diff --git a/app/Http/Controllers/Api/V1/UserRoleController.php b/app/Http/Controllers/Api/V1/UserRoleController.php index afe4cb7..926ef1b 100644 --- a/app/Http/Controllers/Api/V1/UserRoleController.php +++ b/app/Http/Controllers/Api/V1/UserRoleController.php @@ -4,8 +4,8 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Authz\UserRoleGrantRequest; use App\Services\Authz\UserRoleService; -use Illuminate\Http\Request; class UserRoleController extends Controller { @@ -13,27 +13,27 @@ public function index($id) { return ApiResponse::handle(function () use ($id) { return UserRoleService::list((int) $id); - }, '사용자의 역할 목록 조회'); + }, __('message.fetched')); } - public function grant($id, Request $request) + public function grant($id, UserRoleGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return UserRoleService::grant((int) $id, $request->all()); - }, '사용자에게 역할 부여'); + return UserRoleService::grant((int) $id, $request->validated()); + }, __('message.updated')); } - public function revoke($id, Request $request) + public function revoke($id, UserRoleGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return UserRoleService::revoke((int) $id, $request->all()); - }, '사용자의 역할 회수'); + return UserRoleService::revoke((int) $id, $request->validated()); + }, __('message.updated')); } - public function sync($id, Request $request) + public function sync($id, UserRoleGrantRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return UserRoleService::sync((int) $id, $request->all()); - }, '사용자의 역할 동기화'); + return UserRoleService::sync((int) $id, $request->validated()); + }, __('message.updated')); } } diff --git a/app/Http/Requests/Authz/RoleIndexRequest.php b/app/Http/Requests/Authz/RoleIndexRequest.php new file mode 100644 index 0000000..523b4a7 --- /dev/null +++ b/app/Http/Requests/Authz/RoleIndexRequest.php @@ -0,0 +1,23 @@ + 'sometimes|integer|min:1', + 'size' => 'sometimes|integer|min:1|max:100', + 'q' => 'sometimes|nullable|string|max:100', + 'is_hidden' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Authz/RolePermissionGrantRequest.php b/app/Http/Requests/Authz/RolePermissionGrantRequest.php new file mode 100644 index 0000000..c3ed1b6 --- /dev/null +++ b/app/Http/Requests/Authz/RolePermissionGrantRequest.php @@ -0,0 +1,38 @@ + 'sometimes|array', + 'permission_names.*' => 'string|min:1', + 'menus' => 'sometimes|array', + 'menus.*' => 'integer|min:1', + 'actions' => 'sometimes|array', + 'actions.*' => [ + 'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])), + ], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $data = $this->all(); + if (empty($data['permission_names']) && (empty($data['menus']) || empty($data['actions']))) { + $validator->errors()->add('permission_names', __('error.role.permission_input_required')); + } + }); + } +} diff --git a/app/Http/Requests/Authz/RolePermissionToggleRequest.php b/app/Http/Requests/Authz/RolePermissionToggleRequest.php new file mode 100644 index 0000000..668b81b --- /dev/null +++ b/app/Http/Requests/Authz/RolePermissionToggleRequest.php @@ -0,0 +1,24 @@ + 'required|integer|min:1', + 'permission_type' => ['required', 'string', Rule::in($permissionTypes)], + ]; + } +} diff --git a/app/Http/Requests/Authz/RoleStoreRequest.php b/app/Http/Requests/Authz/RoleStoreRequest.php new file mode 100644 index 0000000..772b32b --- /dev/null +++ b/app/Http/Requests/Authz/RoleStoreRequest.php @@ -0,0 +1,31 @@ + [ + 'required', 'string', 'max:100', + Rule::unique('roles', 'name')->where(fn ($q) => $q + ->where('tenant_id', $tenantId) + ->where('guard_name', $guard)), + ], + 'description' => 'nullable|string|max:255', + 'is_hidden' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Authz/RoleUpdateRequest.php b/app/Http/Requests/Authz/RoleUpdateRequest.php new file mode 100644 index 0000000..1698caf --- /dev/null +++ b/app/Http/Requests/Authz/RoleUpdateRequest.php @@ -0,0 +1,32 @@ +route('id'); + + return [ + 'name' => [ + 'sometimes', 'string', 'max:100', + Rule::unique('roles', 'name') + ->where(fn ($q) => $q->where('tenant_id', $tenantId)->where('guard_name', $guard)) + ->ignore($roleId), + ], + 'description' => 'sometimes|nullable|string|max:255', + 'is_hidden' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Authz/UserRoleGrantRequest.php b/app/Http/Requests/Authz/UserRoleGrantRequest.php new file mode 100644 index 0000000..491ab55 --- /dev/null +++ b/app/Http/Requests/Authz/UserRoleGrantRequest.php @@ -0,0 +1,33 @@ + 'sometimes|array', + 'role_names.*' => 'string|min:1', + 'role_ids' => 'sometimes|array', + 'role_ids.*' => 'integer|min:1', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $data = $this->all(); + if (empty($data['role_names']) && empty($data['role_ids'])) { + $validator->errors()->add('role_names', __('error.role.role_input_required')); + } + }); + } +} diff --git a/app/Models/Permissions/Role.php b/app/Models/Permissions/Role.php index cb529e4..abd532f 100644 --- a/app/Models/Permissions/Role.php +++ b/app/Models/Permissions/Role.php @@ -8,13 +8,13 @@ use App\Models\Tenants\Tenant; use App\Traits\Auditable; use App\Traits\BelongsToTenant; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\Permission\Models\Role as SpatieRole; /** * @mixin IdeHelperRole */ -class Role extends Model +class Role extends SpatieRole { use Auditable, BelongsToTenant, SoftDeletes; @@ -34,14 +34,6 @@ class Role extends Model 'tenant_id' => 'integer', ]; - /** - * 관계: 메뉴 권한 (다대다) - */ - public function menuPermissions() - { - return $this->hasMany(RoleMenuPermission::class, 'role_id'); - } - /** * 관계: 테넌트 */ @@ -71,19 +63,6 @@ public function users() ); } - /** - * 관계: 권한 (role_has_permissions 테이블 통해) - */ - public function permissions() - { - return $this->belongsToMany( - Permission::class, - 'role_has_permissions', - 'role_id', - 'permission_id' - ); - } - /** * 스코프: 공개된 역할만 */ diff --git a/app/Models/Permissions/RoleMenuPermission.php b/app/Models/Permissions/RoleMenuPermission.php deleted file mode 100644 index 8535b62..0000000 --- a/app/Models/Permissions/RoleMenuPermission.php +++ /dev/null @@ -1,27 +0,0 @@ -belongsTo(Role::class, 'role_id'); - } - - public function menu() - { - return $this->belongsTo(Menu::class, 'menu_id'); - } -} diff --git a/app/Services/Authz/RolePermissionService.php b/app/Services/Authz/RolePermissionService.php index 86e14d7..d6452b3 100644 --- a/app/Services/Authz/RolePermissionService.php +++ b/app/Services/Authz/RolePermissionService.php @@ -2,10 +2,8 @@ namespace App\Services\Authz; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; +use App\Models\Permissions\Role; use Spatie\Permission\Models\Permission; -use Spatie\Permission\Models\Role; use Spatie\Permission\PermissionRegistrar; class RolePermissionService @@ -18,6 +16,13 @@ protected static function setTeam(int $tenantId): void app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); } + /** 권한 캐시 무효화 */ + protected static function invalidateCache(int $tenantId): void + { + AccessService::bumpVersion($tenantId); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + /** 역할 로드 (테넌트/가드 검증) */ protected static function loadRoleOrError(int $roleId, int $tenantId): ?Role { @@ -37,7 +42,6 @@ protected static function resolvePermissionNames(int $tenantId, array $params): $names = []; if (! empty($params['permission_names']) && is_array($params['permission_names'])) { - // 문자열 배열만 추림 foreach ($params['permission_names'] as $n) { if (is_string($n) && $n !== '') { $names[] = trim($n); @@ -83,7 +87,7 @@ public static function list(int $roleId) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); @@ -104,37 +108,20 @@ public static function grant(int $roleId, array $params = []) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; - } - - // 유효성: 두 방식 중 하나만 요구하진 않지만, 최소 하나는 있어야 함 - $v = Validator::make($params, [ - 'permission_names' => 'sometimes|array', - 'permission_names.*' => 'string|min:1', - 'menus' => 'sometimes|array', - 'menus.*' => 'integer|min:1', - 'actions' => 'sometimes|array', - 'actions.*' => [ - 'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])), - ], - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { - return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); $names = self::resolvePermissionNames($tenantId, $params); if (empty($names)) { - return ['error' => '유효한 퍼미션이 없습니다.', 'code' => 422]; + return ['error' => __('error.role.no_valid_permissions'), 'code' => 422]; } - // Spatie: 이름 배열 부여 OK (teams 컨텍스트 적용됨) $role->givePermissionTo($names); + self::invalidateCache($tenantId); + return 'success'; } @@ -145,35 +132,20 @@ public static function revoke(int $roleId, array $params = []) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'permission_names' => 'sometimes|array', - 'permission_names.*' => 'string|min:1', - 'menus' => 'sometimes|array', - 'menus.*' => 'integer|min:1', - 'actions' => 'sometimes|array', - 'actions.*' => [ - 'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])), - ], - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { - return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); $names = self::resolvePermissionNames($tenantId, $params); if (empty($names)) { - return ['error' => '유효한 퍼미션이 없습니다.', 'code' => 422]; + return ['error' => __('error.role.no_valid_permissions'), 'code' => 422]; } $role->revokePermissionTo($names); + self::invalidateCache($tenantId); + return 'success'; } @@ -184,32 +156,16 @@ public static function sync(int $roleId, array $params = []) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'permission_names' => 'sometimes|array', - 'permission_names.*' => 'string|min:1', - 'menus' => 'sometimes|array', - 'menus.*' => 'integer|min:1', - 'actions' => 'sometimes|array', - 'actions.*' => [ - 'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])), - ], - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) { - return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); - $names = self::resolvePermissionNames($tenantId, $params); // 존재하지 않으면 생성 - // 동기화 + $names = self::resolvePermissionNames($tenantId, $params); $role->syncPermissions($names); + self::invalidateCache($tenantId); + return 'success'; } @@ -226,7 +182,7 @@ public static function matrix(int $roleId) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); @@ -276,7 +232,6 @@ public static function menus() ->orderBy('id', 'asc') ->get(['id', 'parent_id', 'name', 'url', 'icon', 'sort_order', 'is_active']); - // 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) $flatMenus = self::flattenMenuTree($menus->toArray(), null, 0); return [ @@ -298,7 +253,6 @@ protected static function flattenMenuTree(array $menus, ?int $parentId = null, i $menu['has_children'] = count(array_filter($menus, fn ($m) => $m['parent_id'] === $menu['id'])) > 0; $result[] = $menu; - // 자식 메뉴 재귀적으로 추가 $children = self::flattenMenuTree($menus, $menu['id'], $depth + 1); $result = array_merge($result, $children); } @@ -313,15 +267,7 @@ public static function toggle(int $roleId, array $params = []) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'menu_id' => 'required|integer|min:1', - 'permission_type' => ['required', 'string', Rule::in(self::getPermissionTypes())], - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } $menuId = (int) $params['menu_id']; @@ -345,14 +291,12 @@ public static function toggle(int $roleId, array $params = []) ->exists(); if ($exists) { - // 권한 제거 \Illuminate\Support\Facades\DB::table('role_has_permissions') ->where('role_id', $roleId) ->where('permission_id', $permission->id) ->delete(); $newValue = false; } else { - // 권한 부여 \Illuminate\Support\Facades\DB::table('role_has_permissions')->insert([ 'role_id' => $roleId, 'permission_id' => $permission->id, @@ -363,6 +307,8 @@ public static function toggle(int $roleId, array $params = []) // 하위 메뉴에 권한 전파 self::propagateToChildren($roleId, $menuId, $permissionType, $newValue, $tenantId); + self::invalidateCache($tenantId); + return [ 'menu_id' => $menuId, 'permission_type' => $permissionType, @@ -386,7 +332,6 @@ protected static function propagateToChildren(int $roleId, int $parentMenuId, st ]); if ($value) { - // 권한 부여 $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') ->where('role_id', $roleId) ->where('permission_id', $permission->id) @@ -399,14 +344,12 @@ protected static function propagateToChildren(int $roleId, int $parentMenuId, st ]); } } else { - // 권한 제거 \Illuminate\Support\Facades\DB::table('role_has_permissions') ->where('role_id', $roleId) ->where('permission_id', $permission->id) ->delete(); } - // 재귀적으로 하위 메뉴 처리 self::propagateToChildren($roleId, $child->id, $permissionType, $value, $tenantId); } } @@ -418,7 +361,7 @@ public static function allowAll(int $roleId) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); @@ -438,7 +381,6 @@ public static function allowAll(int $roleId) 'tenant_id' => $tenantId, ]); - // 권한 부여 $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') ->where('role_id', $roleId) ->where('permission_id', $permission->id) @@ -453,6 +395,8 @@ public static function allowAll(int $roleId) } } + self::invalidateCache($tenantId); + return 'success'; } @@ -463,7 +407,7 @@ public static function denyAll(int $roleId) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); @@ -491,6 +435,8 @@ public static function denyAll(int $roleId) } } + self::invalidateCache($tenantId); + return 'success'; } @@ -501,7 +447,7 @@ public static function reset(int $roleId) $role = self::loadRoleOrError($roleId, $tenantId); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } self::setTeam($tenantId); @@ -522,7 +468,6 @@ public static function reset(int $roleId) 'tenant_id' => $tenantId, ]); - // 권한 부여 $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') ->where('role_id', $roleId) ->where('permission_id', $permission->id) @@ -536,6 +481,8 @@ public static function reset(int $roleId) } } + self::invalidateCache($tenantId); + return 'success'; } } diff --git a/app/Services/Authz/RoleService.php b/app/Services/Authz/RoleService.php index 5cce42b..be1c812 100644 --- a/app/Services/Authz/RoleService.php +++ b/app/Services/Authz/RoleService.php @@ -4,8 +4,6 @@ use App\Models\Permissions\Role; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; use Spatie\Permission\PermissionRegistrar; class RoleService @@ -51,28 +49,13 @@ public static function store(array $params = []) $tenantId = (int) app('tenant_id'); $userId = app('api_user'); - $v = Validator::make($params, [ - 'name' => [ - 'required', 'string', 'max:100', - Rule::unique('roles', 'name')->where(fn ($q) => $q - ->where('tenant_id', $tenantId) - ->where('guard_name', self::$guard)), - ], - 'description' => 'nullable|string|max:255', - 'is_hidden' => 'sometimes|boolean', - ]); - - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - // Spatie 팀(테넌트) 컨텍스트 app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); $role = Role::create([ 'tenant_id' => $tenantId, 'guard_name' => self::$guard, - 'name' => $v->validated()['name'], + 'name' => $params['name'], 'description' => $params['description'] ?? null, 'is_hidden' => $params['is_hidden'] ?? false, 'created_by' => $userId, @@ -92,7 +75,7 @@ public static function show(int $id) ->find($id); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } return $role; @@ -109,25 +92,10 @@ public static function update(int $id, array $params = []) ->find($id); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } - $v = Validator::make($params, [ - 'name' => [ - 'sometimes', 'string', 'max:100', - Rule::unique('roles', 'name') - ->where(fn ($q) => $q->where('tenant_id', $tenantId)->where('guard_name', self::$guard)) - ->ignore($role->id), - ], - 'description' => 'sometimes|nullable|string|max:255', - 'is_hidden' => 'sometimes|boolean', - ]); - - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - - $updateData = $v->validated(); + $updateData = $params; $updateData['updated_by'] = $userId; $role->fill($updateData)->save(); @@ -146,7 +114,7 @@ public static function destroy(int $id) ->find($id); if (! $role) { - return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.not_found'), 'code' => 404]; } DB::transaction(function () use ($role, $userId) { @@ -158,6 +126,9 @@ public static function destroy(int $id) $role->delete(); }); + AccessService::bumpVersion($tenantId); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + return 'success'; } diff --git a/app/Services/Authz/UserRoleService.php b/app/Services/Authz/UserRoleService.php index 2098d4c..7d075a5 100644 --- a/app/Services/Authz/UserRoleService.php +++ b/app/Services/Authz/UserRoleService.php @@ -3,8 +3,7 @@ namespace App\Services\Authz; use App\Models\Members\User; -use Illuminate\Support\Facades\Validator; -use Spatie\Permission\Models\Role; +use App\Models\Permissions\Role; use Spatie\Permission\PermissionRegistrar; class UserRoleService @@ -17,6 +16,13 @@ protected static function setTeam(int $tenantId): void app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId); } + /** 권한 캐시 무효화 */ + protected static function invalidateCache(int $tenantId): void + { + AccessService::bumpVersion($tenantId); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + /** 유저 로드 (존재 체크) */ protected static function loadUserOrError(int $userId): ?User { @@ -54,14 +60,13 @@ protected static function resolveRoleNames(int $tenantId, array $params): array // 정제 $names = array_values(array_unique(array_filter($names))); - // 존재 확인(필요시 에러 처리 확장 가능) + // 존재 확인 if (! empty($names)) { - $count = Role::query() + Role::query() ->where('tenant_id', $tenantId) ->where('guard_name', self::$guard) ->whereIn('name', $names) ->count(); - // if ($count !== count($names)) { ... 필요시 상세 에러 반환 } } return $names; @@ -74,12 +79,11 @@ public static function list(int $userId) $user = self::loadUserOrError($userId); if (! $user) { - return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404]; + return ['error' => __('error.role.user_not_found'), 'code' => 404]; } self::setTeam($tenantId); - // 현재 테넌트의 역할만 $builder = $user->roles() ->where('roles.tenant_id', $tenantId) ->where('roles.guard_name', self::$guard) @@ -96,30 +100,19 @@ public static function grant(int $userId, array $params = []) $user = self::loadUserOrError($userId); if (! $user) { - return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'role_names' => 'sometimes|array', - 'role_names.*' => 'string|min:1', - 'role_ids' => 'sometimes|array', - 'role_ids.*' => 'integer|min:1', - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['role_names']) && empty($params['role_ids'])) { - return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.user_not_found'), 'code' => 404]; } self::setTeam($tenantId); $names = self::resolveRoleNames($tenantId, $params); if (empty($names)) { - return ['error' => '유효한 역할이 없습니다.', 'code' => 422]; + return ['error' => __('error.role.no_valid_roles'), 'code' => 422]; } - $user->assignRole($names); // teams 컨텍스트 적용됨 + $user->assignRole($names); + + self::invalidateCache($tenantId); return 'success'; } @@ -131,30 +124,19 @@ public static function revoke(int $userId, array $params = []) $user = self::loadUserOrError($userId); if (! $user) { - return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'role_names' => 'sometimes|array', - 'role_names.*' => 'string|min:1', - 'role_ids' => 'sometimes|array', - 'role_ids.*' => 'integer|min:1', - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['role_names']) && empty($params['role_ids'])) { - return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.user_not_found'), 'code' => 404]; } self::setTeam($tenantId); $names = self::resolveRoleNames($tenantId, $params); if (empty($names)) { - return ['error' => '유효한 역할이 없습니다.', 'code' => 422]; + return ['error' => __('error.role.no_valid_roles'), 'code' => 422]; } - $user->removeRole($names); // 배열 허용 + $user->removeRole($names); + + self::invalidateCache($tenantId); return 'success'; } @@ -166,32 +148,20 @@ public static function sync(int $userId, array $params = []) $user = self::loadUserOrError($userId); if (! $user) { - return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404]; - } - - $v = Validator::make($params, [ - 'role_names' => 'sometimes|array', - 'role_names.*' => 'string|min:1', - 'role_ids' => 'sometimes|array', - 'role_ids.*' => 'integer|min:1', - ]); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - if (empty($params['role_names']) && empty($params['role_ids'])) { - return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422]; + return ['error' => __('error.role.user_not_found'), 'code' => 404]; } self::setTeam($tenantId); $names = self::resolveRoleNames($tenantId, $params); if (empty($names)) { - // 정책상 빈 목록 sync 허용 시: $user->syncRoles([]) 로 전부 제거 가능 - return ['error' => '유효한 역할이 없습니다.', 'code' => 422]; + return ['error' => __('error.role.no_valid_roles'), 'code' => 422]; } $user->syncRoles($names); + self::invalidateCache($tenantId); + return 'success'; } } diff --git a/app/Swagger/v1/RoleApi.php b/app/Swagger/v1/RoleApi.php index 8c7700d..272e153 100644 --- a/app/Swagger/v1/RoleApi.php +++ b/app/Swagger/v1/RoleApi.php @@ -3,7 +3,7 @@ namespace App\Swagger\v1; /** - * @OA\Tag(name="Role", description="역할 관리(목록/조회/등록/수정/삭제)") + * @OA\Tag(name="Role", description="역할 관리(목록/조회/등록/수정/삭제/통계/활성)") */ /** @@ -18,6 +18,9 @@ * @OA\Property(property="name", type="string", example="menu-manager"), * @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할"), * @OA\Property(property="guard_name", type="string", example="api"), + * @OA\Property(property="is_hidden", type="boolean", example=false), + * @OA\Property(property="permissions_count", type="integer", example=12), + * @OA\Property(property="users_count", type="integer", example=3), * @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-16 10:00:00"), * @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-16 10:00:00") * ) @@ -47,7 +50,8 @@ * required={"name"}, * * @OA\Property(property="name", type="string", example="menu-manager", description="역할명(테넌트+가드 내 고유)"), - * @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할") + * @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할"), + * @OA\Property(property="is_hidden", type="boolean", example=false, description="숨김 여부") * ) * * @OA\Schema( @@ -55,7 +59,19 @@ * type="object", * * @OA\Property(property="name", type="string", example="menu-admin"), - * @OA\Property(property="description", type="string", nullable=true, example="설명 변경") + * @OA\Property(property="description", type="string", nullable=true, example="설명 변경"), + * @OA\Property(property="is_hidden", type="boolean", example=false) + * ) + * + * @OA\Schema( + * schema="RoleStats", + * type="object", + * description="역할 통계", + * + * @OA\Property(property="total", type="integer", example=5), + * @OA\Property(property="visible", type="integer", example=3), + * @OA\Property(property="hidden", type="integer", example=2), + * @OA\Property(property="with_users", type="integer", example=4) * ) */ class RoleApi @@ -64,13 +80,14 @@ class RoleApi * @OA\Get( * path="/api/v1/roles", * summary="역할 목록 조회", - * description="테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)", + * description="테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색, is_hidden으로 필터)", * tags={"Role"}, * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)), * @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", example=10)), * @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string", example="read")), + * @OA\Parameter(name="is_hidden", in="query", required=false, description="숨김 상태 필터", @OA\Schema(type="boolean", example=false)), * * @OA\Response(response=200, description="목록 조회 성공", * @@ -208,4 +225,62 @@ public function update() {} * ) */ public function destroy() {} + + /** + * @OA\Get( + * path="/api/v1/roles/stats", + * summary="역할 통계 조회", + * description="테넌트 범위 내 역할 통계(전체/공개/숨김/사용자 보유)를 반환합니다.", + * tags={"Role"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Response(response=200, description="통계 조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RoleStats")) + * } + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function stats() {} + + /** + * @OA\Get( + * path="/api/v1/roles/active", + * summary="활성 역할 목록 (드롭다운용)", + * description="숨겨지지 않은 활성 역할 목록을 이름순으로 반환합니다. (id, name, description만 포함)", + * tags={"Role"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Response(response=200, description="목록 조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="array", + * + * @OA\Items(type="object", + * + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="admin"), + * @OA\Property(property="description", type="string", nullable=true, example="관리자") + * ) + * )) + * } + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function active() {} } diff --git a/app/Swagger/v1/RolePermissionApi.php b/app/Swagger/v1/RolePermissionApi.php index 8f83b01..00eedd0 100644 --- a/app/Swagger/v1/RolePermissionApi.php +++ b/app/Swagger/v1/RolePermissionApi.php @@ -5,7 +5,7 @@ /** * @OA\Tag( * name="RolePermission", - * description="역할-퍼미션 매핑(조회/부여/회수/동기화)" + * description="역할-퍼미션 매핑(조회/부여/회수/동기화/매트릭스/토글)" * ) */ @@ -96,6 +96,64 @@ * ) * } * ) + * + * @OA\Schema( + * schema="PermissionMenuTree", + * type="object", + * description="권한 매트릭스용 메뉴 트리", + * + * @OA\Property(property="menus", type="array", + * + * @OA\Items(type="object", + * + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="parent_id", type="integer", nullable=true, example=null), + * @OA\Property(property="name", type="string", example="대시보드"), + * @OA\Property(property="url", type="string", nullable=true, example="/dashboard"), + * @OA\Property(property="icon", type="string", nullable=true, example="dashboard"), + * @OA\Property(property="sort_order", type="integer", example=1), + * @OA\Property(property="is_active", type="boolean", example=true), + * @OA\Property(property="depth", type="integer", example=0), + * @OA\Property(property="has_children", type="boolean", example=true) + * ) + * ), + * @OA\Property(property="permission_types", type="array", @OA\Items(type="string"), example={"view","create","update","delete","approve","export","manage"}) + * ) + * + * @OA\Schema( + * schema="RolePermissionMatrix", + * type="object", + * description="역할의 권한 매트릭스", + * + * @OA\Property(property="role", type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="admin"), + * @OA\Property(property="description", type="string", nullable=true, example="관리자") + * ), + * @OA\Property(property="permission_types", type="array", @OA\Items(type="string"), example={"view","create","update","delete","approve","export","manage"}), + * @OA\Property(property="permissions", type="object", description="메뉴ID를 키로 한 권한 맵", + * example={"101": {"view": true, "create": true}, "102": {"view": true}}, + * additionalProperties=true + * ) + * ) + * + * @OA\Schema( + * schema="RolePermissionToggleRequest", + * type="object", + * required={"menu_id","permission_type"}, + * + * @OA\Property(property="menu_id", type="integer", example=101, description="메뉴 ID"), + * @OA\Property(property="permission_type", type="string", example="view", description="권한 유형 (view, create, update, delete, approve, export, manage)") + * ) + * + * @OA\Schema( + * schema="RolePermissionToggleResponse", + * type="object", + * + * @OA\Property(property="menu_id", type="integer", example=101), + * @OA\Property(property="permission_type", type="string", example="view"), + * @OA\Property(property="granted", type="boolean", example=true, description="토글 후 권한 부여 상태") + * ) */ class RolePermissionApi { @@ -193,4 +251,142 @@ public function revoke() {} * ) */ public function sync() {} + + /** + * @OA\Get( + * path="/api/v1/role-permissions/menus", + * summary="권한 매트릭스용 메뉴 트리 조회", + * description="활성 메뉴를 플랫 배열(depth 포함)로 반환하고, 사용 가능한 권한 유형 목록을 함께 반환합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PermissionMenuTree")) + * } + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function menus() {} + + /** + * @OA\Get( + * path="/api/v1/roles/{id}/permissions/matrix", + * summary="역할의 권한 매트릭스 조회", + * description="해당 역할에 부여된 메뉴별 권한 매트릭스를 반환합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RolePermissionMatrix")) + * } + * ) + * ), + * + * @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function matrix() {} + + /** + * @OA\Post( + * path="/api/v1/roles/{id}/permissions/toggle", + * summary="특정 메뉴의 특정 권한 토글", + * description="지정한 메뉴+권한 유형의 부여 상태를 반전합니다. 하위 메뉴에 재귀적으로 전파합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RolePermissionToggleRequest")), + * + * @OA\Response(response=200, description="토글 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RolePermissionToggleResponse")) + * } + * ) + * ), + * + * @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function toggle() {} + + /** + * @OA\Post( + * path="/api/v1/roles/{id}/permissions/allow-all", + * summary="모든 권한 허용", + * description="해당 역할에 모든 활성 메뉴의 모든 권한 유형을 일괄 부여합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1), + * + * @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function allowAll() {} + + /** + * @OA\Post( + * path="/api/v1/roles/{id}/permissions/deny-all", + * summary="모든 권한 거부", + * description="해당 역할의 모든 메뉴 권한을 일괄 제거합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1), + * + * @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function denyAll() {} + + /** + * @OA\Post( + * path="/api/v1/roles/{id}/permissions/reset", + * summary="기본 권한으로 초기화 (view만 허용)", + * description="해당 역할의 모든 권한을 제거한 후, 모든 활성 메뉴에 view 권한만 부여합니다.", + * tags={"RolePermission"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1), + * + * @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function reset() {} } diff --git a/config/permission.php b/config/permission.php index e989f4d..d2f1004 100644 --- a/config/permission.php +++ b/config/permission.php @@ -24,7 +24,7 @@ * `Spatie\Permission\Contracts\Role` contract. */ - 'role' => Spatie\Permission\Models\Role::class, + 'role' => App\Models\Permissions\Role::class, ], diff --git a/lang/en/error.php b/lang/en/error.php index 576ec04..9139d20 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -32,6 +32,16 @@ // Server errors 'server_error' => 'An internal server error occurred.', // 5xx + // Role/Permission related + 'role' => [ + 'not_found' => 'Role not found.', + 'permission_input_required' => 'Either permission_names or menus+actions is required.', + 'no_valid_permissions' => 'No valid permissions found.', + 'role_input_required' => 'Either role_names or role_ids is required.', + 'no_valid_roles' => 'No valid roles found.', + 'user_not_found' => 'User not found.', + ], + // Estimate related errors 'estimate' => [ 'cannot_delete_sent_or_approved' => 'Cannot delete estimates that have been sent or approved.', diff --git a/lang/ko/error.php b/lang/ko/error.php index f029c2b..106d360 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -39,6 +39,16 @@ // 서버 오류 'server_error' => '서버 처리 중 오류가 발생했습니다.', // 5xx 일반 + // 역할/권한 관련 + 'role' => [ + 'not_found' => '역할을 찾을 수 없습니다.', + 'permission_input_required' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', + 'no_valid_permissions' => '유효한 퍼미션이 없습니다.', + 'role_input_required' => 'role_names 또는 role_ids 중 하나는 필요합니다.', + 'no_valid_roles' => '유효한 역할이 없습니다.', + 'user_not_found' => '사용자를 찾을 수 없습니다.', + ], + // 견적 관련 에러 'estimate' => [ 'cannot_delete_sent_or_approved' => '발송되었거나 승인된 견적은 삭제할 수 없습니다.', diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php index ede81c7..8dca56e 100644 --- a/routes/api/v1/common.php +++ b/routes/api/v1/common.php @@ -57,7 +57,7 @@ }); // Role API -Route::prefix('roles')->group(function () { +Route::middleware(['perm.map', 'permission'])->prefix('roles')->group(function () { Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index'); Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store'); Route::get('/stats', [RoleController::class, 'stats'])->name('v1.roles.stats'); @@ -68,10 +68,10 @@ }); // Role Permission API - 공통 -Route::get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus'); +Route::middleware(['perm.map', 'permission'])->get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus'); // Role Permission API - 역할별 -Route::prefix('roles/{id}/permissions')->group(function () { +Route::middleware(['perm.map', 'permission'])->prefix('roles/{id}/permissions')->group(function () { Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); Route::post('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index 1b9fb4d..1cd2532 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -46368,7 +46368,7 @@ "Role" ], "summary": "역할 목록 조회", - "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)", + "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색, is_hidden으로 필터)", "operationId": "2fe3440eb56182754caf817600b13375", "parameters": [ { @@ -46397,6 +46397,16 @@ "type": "string", "example": "read" } + }, + { + "name": "is_hidden", + "in": "query", + "description": "숨김 상태 필터", + "required": false, + "schema": { + "type": "boolean", + "example": false + } } ], "responses": { @@ -46853,6 +46863,148 @@ ] } }, + "/api/v1/roles/stats": { + "get": { + "tags": [ + "Role" + ], + "summary": "역할 통계 조회", + "description": "테넌트 범위 내 역할 통계(전체/공개/숨김/사용자 보유)를 반환합니다.", + "operationId": "419d5a08537494bf256b10661e221944", + "responses": { + "200": { + "description": "통계 조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/RoleStats" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/active": { + "get": { + "tags": [ + "Role" + ], + "summary": "활성 역할 목록 (드롭다운용)", + "description": "숨겨지지 않은 활성 역할 목록을 이름순으로 반환합니다. (id, name, description만 포함)", + "operationId": "8663eac59de3903354a3d5dd4502a5bf", + "responses": { + "200": { + "description": "목록 조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "admin" + }, + "description": { + "type": "string", + "example": "관리자", + "nullable": true + } + }, + "type": "object" + } + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/roles/{id}/permissions": { "get": { "tags": [ @@ -47245,6 +47397,467 @@ ] } }, + "/api/v1/role-permissions/menus": { + "get": { + "tags": [ + "RolePermission" + ], + "summary": "권한 매트릭스용 메뉴 트리 조회", + "description": "활성 메뉴를 플랫 배열(depth 포함)로 반환하고, 사용 가능한 권한 유형 목록을 함께 반환합니다.", + "operationId": "1eea6074af7fe23108049fc436ae4b8f", + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/PermissionMenuTree" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/{id}/permissions/matrix": { + "get": { + "tags": [ + "RolePermission" + ], + "summary": "역할의 권한 매트릭스 조회", + "description": "해당 역할에 부여된 메뉴별 권한 매트릭스를 반환합니다.", + "operationId": "18e9a32f62613b9cd3d41e79f500d122", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/RolePermissionMatrix" + } + }, + "type": "object" + } + ] + } + } + } + }, + "404": { + "description": "역할 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/{id}/permissions/toggle": { + "post": { + "tags": [ + "RolePermission" + ], + "summary": "특정 메뉴의 특정 권한 토글", + "description": "지정한 메뉴+권한 유형의 부여 상태를 반전합니다. 하위 메뉴에 재귀적으로 전파합니다.", + "operationId": "cd6302edade7b8f79c39a85f8c369638", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolePermissionToggleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "토글 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/RolePermissionToggleResponse" + } + }, + "type": "object" + } + ] + } + } + } + }, + "404": { + "description": "역할 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "검증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/{id}/permissions/allow-all": { + "post": { + "tags": [ + "RolePermission" + ], + "summary": "모든 권한 허용", + "description": "해당 역할에 모든 활성 메뉴의 모든 권한 유형을 일괄 부여합니다.", + "operationId": "ab526a580d6926ef0971582b9aeb1d58", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "역할 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/{id}/permissions/deny-all": { + "post": { + "tags": [ + "RolePermission" + ], + "summary": "모든 권한 거부", + "description": "해당 역할의 모든 메뉴 권한을 일괄 제거합니다.", + "operationId": "f0120556f6104f5778f13349a5eec469", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "역할 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/roles/{id}/permissions/reset": { + "post": { + "tags": [ + "RolePermission" + ], + "summary": "기본 권한으로 초기화 (view만 허용)", + "description": "해당 역할의 모든 권한을 제거한 후, 모든 활성 메뉴에 view 권한만 부여합니다.", + "operationId": "7d0ce4d8a4116908a9639c70dc7dba61", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "역할 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/sales": { "get": { "tags": [ @@ -85344,6 +85957,18 @@ "type": "string", "example": "api" }, + "is_hidden": { + "type": "boolean", + "example": false + }, + "permissions_count": { + "type": "integer", + "example": 12 + }, + "users_count": { + "type": "integer", + "example": 3 + }, "created_at": { "type": "string", "format": "date-time", @@ -85404,6 +86029,11 @@ "type": "string", "example": "메뉴 관리 역할", "nullable": true + }, + "is_hidden": { + "description": "숨김 여부", + "type": "boolean", + "example": false } }, "type": "object" @@ -85418,6 +86048,32 @@ "type": "string", "example": "설명 변경", "nullable": true + }, + "is_hidden": { + "type": "boolean", + "example": false + } + }, + "type": "object" + }, + "RoleStats": { + "description": "역할 통계", + "properties": { + "total": { + "type": "integer", + "example": 5 + }, + "visible": { + "type": "integer", + "example": 3 + }, + "hidden": { + "type": "integer", + "example": 2 + }, + "with_users": { + "type": "integer", + "example": 4 } }, "type": "object" @@ -85630,6 +86286,164 @@ } ] }, + "PermissionMenuTree": { + "description": "권한 매트릭스용 메뉴 트리", + "properties": { + "menus": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "parent_id": { + "type": "integer", + "example": null, + "nullable": true + }, + "name": { + "type": "string", + "example": "대시보드" + }, + "url": { + "type": "string", + "example": "/dashboard", + "nullable": true + }, + "icon": { + "type": "string", + "example": "dashboard", + "nullable": true + }, + "sort_order": { + "type": "integer", + "example": 1 + }, + "is_active": { + "type": "boolean", + "example": true + }, + "depth": { + "type": "integer", + "example": 0 + }, + "has_children": { + "type": "boolean", + "example": true + } + }, + "type": "object" + } + }, + "permission_types": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "view", + "create", + "update", + "delete", + "approve", + "export", + "manage" + ] + } + }, + "type": "object" + }, + "RolePermissionMatrix": { + "description": "역할의 권한 매트릭스", + "properties": { + "role": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "admin" + }, + "description": { + "type": "string", + "example": "관리자", + "nullable": true + } + }, + "type": "object" + }, + "permission_types": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "view", + "create", + "update", + "delete", + "approve", + "export", + "manage" + ] + }, + "permissions": { + "description": "메뉴ID를 키로 한 권한 맵", + "type": "object", + "example": { + "101": { + "view": true, + "create": true + }, + "102": { + "view": true + } + }, + "additionalProperties": true + } + }, + "type": "object" + }, + "RolePermissionToggleRequest": { + "required": [ + "menu_id", + "permission_type" + ], + "properties": { + "menu_id": { + "description": "메뉴 ID", + "type": "integer", + "example": 101 + }, + "permission_type": { + "description": "권한 유형 (view, create, update, delete, approve, export, manage)", + "type": "string", + "example": "view" + } + }, + "type": "object" + }, + "RolePermissionToggleResponse": { + "properties": { + "menu_id": { + "type": "integer", + "example": 101 + }, + "permission_type": { + "type": "string", + "example": "view" + }, + "granted": { + "description": "토글 후 권한 부여 상태", + "type": "boolean", + "example": true + } + }, + "type": "object" + }, "Sale": { "description": "매출 정보", "properties": {