refactor: [authz] 역할/권한 API 품질 개선

- Validator::make를 FormRequest로 분리 (6개 생성)
- 하드코딩 한글 문자열을 i18n 키로 교체
- RoleMenuPermission 데드코드 제거
- Role 모델 SpatieRole 상속으로 일원화
- 권한 변경 후 캐시 무효화 추가 (AccessService::bumpVersion)
- 미문서화 8개 Swagger 엔드포인트 추가
- 역할/권한 라우트에 perm.map+permission 미들웨어 추가
This commit is contained in:
김보곤
2026-02-20 21:59:26 +09:00
parent 555fd196f5
commit 1dd9057540
21 changed files with 1400 additions and 271 deletions

View File

@@ -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';
}
}