diff --git a/app/Http/Controllers/Api/V1/RoleController.php b/app/Http/Controllers/Api/V1/RoleController.php index 27ad9d6..789a159 100644 --- a/app/Http/Controllers/Api/V1/RoleController.php +++ b/app/Http/Controllers/Api/V1/RoleController.php @@ -9,38 +9,73 @@ class RoleController extends Controller { + /** + * 역할 목록 조회 + */ public function index(Request $request) { return ApiResponse::handle(function () use ($request) { return RoleService::index($request->all()); - }, '역할 목록 조회'); + }, __('message.fetched')); } + /** + * 역할 생성 + */ public function store(Request $request) { return ApiResponse::handle(function () use ($request) { return RoleService::store($request->all()); - }, '역할 생성'); + }, __('message.created')); } + /** + * 역할 상세 조회 + */ public function show($id) { return ApiResponse::handle(function () use ($id) { return RoleService::show((int) $id); - }, '역할 상세 조회'); + }, __('message.fetched')); } + /** + * 역할 수정 + */ public function update(Request $request, $id) { return ApiResponse::handle(function () use ($request, $id) { return RoleService::update((int) $id, $request->all()); - }, '역할 수정'); + }, __('message.updated')); } + /** + * 역할 삭제 + */ public function destroy($id) { return ApiResponse::handle(function () use ($id) { return RoleService::destroy((int) $id); - }, '역할 삭제'); + }, __('message.deleted')); + } + + /** + * 역할 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(function () { + return RoleService::stats(); + }, __('message.fetched')); + } + + /** + * 활성 역할 목록 (드롭다운용) + */ + public function active() + { + return ApiResponse::handle(function () { + return RoleService::active(); + }, __('message.fetched')); } } diff --git a/app/Http/Controllers/Api/V1/RolePermissionController.php b/app/Http/Controllers/Api/V1/RolePermissionController.php index cea9fe6..1e5c2c3 100644 --- a/app/Http/Controllers/Api/V1/RolePermissionController.php +++ b/app/Http/Controllers/Api/V1/RolePermissionController.php @@ -36,4 +36,64 @@ public function sync($id, Request $request) return RolePermissionService::sync((int) $id, $request->all()); }, '역할 퍼미션 동기화'); } + + /** + * 권한 매트릭스용 메뉴 트리 조회 + */ + public function menus() + { + return ApiResponse::handle(function () { + return RolePermissionService::menus(); + }, __('message.fetched')); + } + + /** + * 역할의 권한 매트릭스 조회 + */ + public function matrix($id) + { + return ApiResponse::handle(function () use ($id) { + return RolePermissionService::matrix((int) $id); + }, __('message.fetched')); + } + + /** + * 특정 메뉴의 특정 권한 토글 + */ + public function toggle($id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return RolePermissionService::toggle((int) $id, $request->all()); + }, __('message.updated')); + } + + /** + * 모든 권한 허용 + */ + public function allowAll($id) + { + return ApiResponse::handle(function () use ($id) { + return RolePermissionService::allowAll((int) $id); + }, __('message.updated')); + } + + /** + * 모든 권한 거부 + */ + public function denyAll($id) + { + return ApiResponse::handle(function () use ($id) { + return RolePermissionService::denyAll((int) $id); + }, __('message.updated')); + } + + /** + * 기본 권한으로 초기화 (view만 허용) + */ + public function reset($id) + { + return ApiResponse::handle(function () use ($id) { + return RolePermissionService::reset((int) $id); + }, __('message.updated')); + } } diff --git a/app/Models/Permissions/Role.php b/app/Models/Permissions/Role.php index 06a4abb..98aa544 100644 --- a/app/Models/Permissions/Role.php +++ b/app/Models/Permissions/Role.php @@ -5,29 +5,83 @@ use App\Models\Commons\IdeHelperRole; use App\Models\Members\UserRole; use App\Models\Tenants\Tenant; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @mixin IdeHelperRole */ class Role extends Model { + use BelongsToTenant, SoftDeletes; + protected $fillable = [ - 'tenant_id', 'name', 'description', + 'tenant_id', + 'name', + 'guard_name', + 'description', + 'is_hidden', + 'created_by', + 'updated_by', + 'deleted_by', ]; + protected $casts = [ + 'is_hidden' => 'boolean', + 'tenant_id' => 'integer', + ]; + + /** + * 관계: 메뉴 권한 (다대다) + */ public function menuPermissions() { return $this->hasMany(RoleMenuPermission::class, 'role_id'); } + /** + * 관계: 테넌트 + */ public function tenant() { return $this->belongsTo(Tenant::class); } + /** + * 관계: 사용자 역할 + */ public function userRoles() { return $this->hasMany(UserRole::class); } + + /** + * 관계: 권한 (role_has_permissions 테이블 통해) + */ + public function permissions() + { + return $this->belongsToMany( + Permission::class, + 'role_has_permissions', + 'role_id', + 'permission_id' + ); + } + + /** + * 스코프: 공개된 역할만 + */ + public function scopeVisible($query) + { + return $query->where('is_hidden', false); + } + + /** + * 스코프: 숨겨진 역할만 + */ + public function scopeHidden($query) + { + return $query->where('is_hidden', true); + } } diff --git a/app/Services/Authz/RolePermissionService.php b/app/Services/Authz/RolePermissionService.php index f8ef631..b76ce3f 100644 --- a/app/Services/Authz/RolePermissionService.php +++ b/app/Services/Authz/RolePermissionService.php @@ -212,4 +212,330 @@ public static function sync(int $roleId, array $params = []) return 'success'; } + + /** 권한 유형 목록 */ + protected static function getPermissionTypes(): array + { + return config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']); + } + + /** 역할의 권한 매트릭스 조회 */ + public static function matrix(int $roleId) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (! $role) { + return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + } + + self::setTeam($tenantId); + + // 역할에 부여된 권한 조회 + $rolePermissions = \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id') + ->where('role_has_permissions.role_id', $roleId) + ->where('permissions.guard_name', self::$guard) + ->where('permissions.name', 'like', 'menu:%') + ->pluck('permissions.name') + ->toArray(); + + $permissions = []; + foreach ($rolePermissions as $permName) { + if (preg_match('/^menu:(\d+)\.(\w+)$/', $permName, $matches)) { + $menuId = (int) $matches[1]; + $type = $matches[2]; + + if (! isset($permissions[$menuId])) { + $permissions[$menuId] = []; + } + + $permissions[$menuId][$type] = true; + } + } + + return [ + 'role' => [ + 'id' => $role->id, + 'name' => $role->name, + 'description' => $role->description, + ], + 'permission_types' => self::getPermissionTypes(), + 'permissions' => $permissions, + ]; + } + + /** 메뉴 트리 조회 (권한 매트릭스 표시용) */ + public static function menus() + { + $tenantId = (int) app('tenant_id'); + + $menus = \App\Models\Menus\Menu::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('sort_order', 'asc') + ->orderBy('id', 'asc') + ->get(['id', 'parent_id', 'name', 'code', 'path', 'icon', 'sort_order', 'is_active']); + + // 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) + $flatMenus = self::flattenMenuTree($menus->toArray(), null, 0); + + return [ + 'menus' => $flatMenus, + 'permission_types' => self::getPermissionTypes(), + ]; + } + + /** 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) */ + protected static function flattenMenuTree(array $menus, ?int $parentId = null, int $depth = 0): array + { + $result = []; + + $filteredMenus = array_filter($menus, fn ($m) => $m['parent_id'] === $parentId); + usort($filteredMenus, fn ($a, $b) => ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0)); + + foreach ($filteredMenus as $menu) { + $menu['depth'] = $depth; + $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); + } + + return $result; + } + + /** 특정 메뉴의 특정 권한 토글 */ + public static function toggle(int $roleId, array $params = []) + { + $tenantId = (int) app('tenant_id'); + + $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]; + } + + $menuId = (int) $params['menu_id']; + $permissionType = $params['permission_type']; + + self::setTeam($tenantId); + + $permissionName = "menu:{$menuId}.{$permissionType}"; + + // 권한 생성 또는 조회 + $permission = Permission::firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => self::$guard, + 'tenant_id' => $tenantId, + ]); + + // 현재 권한 상태 확인 + $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permission->id) + ->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, + ]); + $newValue = true; + } + + // 하위 메뉴에 권한 전파 + self::propagateToChildren($roleId, $menuId, $permissionType, $newValue, $tenantId); + + return [ + 'menu_id' => $menuId, + 'permission_type' => $permissionType, + 'granted' => $newValue, + ]; + } + + /** 하위 메뉴에 권한 전파 */ + protected static function propagateToChildren(int $roleId, int $parentMenuId, string $permissionType, bool $value, int $tenantId): void + { + $children = \App\Models\Menus\Menu::where('parent_id', $parentMenuId) + ->where('tenant_id', $tenantId) + ->get(); + + foreach ($children as $child) { + $permissionName = "menu:{$child->id}.{$permissionType}"; + $permission = Permission::firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => self::$guard, + 'tenant_id' => $tenantId, + ]); + + if ($value) { + // 권한 부여 + $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permission->id) + ->exists(); + + if (! $exists) { + \Illuminate\Support\Facades\DB::table('role_has_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permission->id, + ]); + } + } 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); + } + } + + /** 모든 권한 허용 */ + public static function allowAll(int $roleId) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (! $role) { + return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + } + + self::setTeam($tenantId); + + $menus = \App\Models\Menus\Menu::where('tenant_id', $tenantId) + ->where('is_active', true) + ->get(); + + $permissionTypes = self::getPermissionTypes(); + + foreach ($menus as $menu) { + foreach ($permissionTypes as $type) { + $permissionName = "menu:{$menu->id}.{$type}"; + $permission = Permission::firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => self::$guard, + 'tenant_id' => $tenantId, + ]); + + // 권한 부여 + $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permission->id) + ->exists(); + + if (! $exists) { + \Illuminate\Support\Facades\DB::table('role_has_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permission->id, + ]); + } + } + } + + return 'success'; + } + + /** 모든 권한 거부 */ + public static function denyAll(int $roleId) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (! $role) { + return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + } + + self::setTeam($tenantId); + + $menus = \App\Models\Menus\Menu::where('tenant_id', $tenantId) + ->where('is_active', true) + ->get(); + + $permissionTypes = self::getPermissionTypes(); + + foreach ($menus as $menu) { + foreach ($permissionTypes as $type) { + $permissionName = "menu:{$menu->id}.{$type}"; + $permission = Permission::where('name', $permissionName) + ->where('guard_name', self::$guard) + ->where('tenant_id', $tenantId) + ->first(); + + if ($permission) { + \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permission->id) + ->delete(); + } + } + } + + return 'success'; + } + + /** 기본 권한으로 초기화 (view만 허용) */ + public static function reset(int $roleId) + { + $tenantId = (int) app('tenant_id'); + + $role = self::loadRoleOrError($roleId, $tenantId); + if (! $role) { + return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; + } + + self::setTeam($tenantId); + + // 1. 먼저 모든 권한 제거 + self::denyAll($roleId); + + // 2. view 권한만 허용 + $menus = \App\Models\Menus\Menu::where('tenant_id', $tenantId) + ->where('is_active', true) + ->get(); + + foreach ($menus as $menu) { + $permissionName = "menu:{$menu->id}.view"; + $permission = Permission::firstOrCreate([ + 'name' => $permissionName, + 'guard_name' => self::$guard, + 'tenant_id' => $tenantId, + ]); + + // 권한 부여 + $exists = \Illuminate\Support\Facades\DB::table('role_has_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permission->id) + ->exists(); + + if (! $exists) { + \Illuminate\Support\Facades\DB::table('role_has_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permission->id, + ]); + } + } + + return 'success'; + } } diff --git a/app/Services/Authz/RoleService.php b/app/Services/Authz/RoleService.php index 1c1b267..830c4e3 100644 --- a/app/Services/Authz/RoleService.php +++ b/app/Services/Authz/RoleService.php @@ -22,8 +22,10 @@ public static function index(array $params = []) $query = Role::query() ->where('tenant_id', $tenantId) - ->where('guard_name', self::$guard); + ->where('guard_name', self::$guard) + ->withCount(['permissions', 'users']); + // 검색 필터 if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") @@ -31,6 +33,12 @@ public static function index(array $params = []) }); } + // 숨김 상태 필터 + if (isset($params['is_hidden'])) { + $isHidden = filter_var($params['is_hidden'], FILTER_VALIDATE_BOOLEAN); + $query->where('is_hidden', $isHidden); + } + $list = $query->orderByDesc('id') ->paginate($size, ['*'], 'page', $page); @@ -41,6 +49,7 @@ public static function index(array $params = []) public static function store(array $params = []) { $tenantId = (int) app('tenant_id'); + $userId = app('api_user'); $v = Validator::make($params, [ 'name' => [ @@ -50,6 +59,7 @@ public static function store(array $params = []) ->where('guard_name', self::$guard)), ], 'description' => 'nullable|string|max:255', + 'is_hidden' => 'sometimes|boolean', ]); if ($v->fails()) { @@ -64,9 +74,11 @@ public static function store(array $params = []) 'guard_name' => self::$guard, 'name' => $v->validated()['name'], 'description' => $params['description'] ?? null, + 'is_hidden' => $params['is_hidden'] ?? false, + 'created_by' => $userId, ]); - return $role; + return $role->loadCount(['permissions', 'users']); } /** 단건 */ @@ -76,6 +88,7 @@ public static function show(int $id) $role = Role::where('tenant_id', $tenantId) ->where('guard_name', self::$guard) + ->withCount(['permissions', 'users']) ->find($id); if (! $role) { @@ -89,6 +102,7 @@ public static function show(int $id) public static function update(int $id, array $params = []) { $tenantId = (int) app('tenant_id'); + $userId = app('api_user'); $role = Role::where('tenant_id', $tenantId) ->where('guard_name', self::$guard) @@ -106,21 +120,26 @@ public static function update(int $id, array $params = []) ->ignore($role->id), ], 'description' => 'sometimes|nullable|string|max:255', + 'is_hidden' => 'sometimes|boolean', ]); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } - $role->fill($v->validated())->save(); + $updateData = $v->validated(); + $updateData['updated_by'] = $userId; - return $role->fresh(); + $role->fill($updateData)->save(); + + return $role->fresh()->loadCount(['permissions', 'users']); } - /** 삭제 (하드삭제) */ + /** 삭제 (Soft Delete) */ public static function destroy(int $id) { $tenantId = (int) app('tenant_id'); + $userId = app('api_user'); $role = Role::where('tenant_id', $tenantId) ->where('guard_name', self::$guard) @@ -130,10 +149,43 @@ public static function destroy(int $id) return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404]; } - DB::transaction(function () use ($role) { - $role->delete(); // Spatie Role 기본: soft delete 없음 + DB::transaction(function () use ($role, $userId) { + // 권한 연결 해제 + $role->permissions()->detach(); + + // Soft Delete + $role->update(['deleted_by' => $userId]); + $role->delete(); }); return 'success'; } + + /** 통계 조회 */ + public static function stats() + { + $tenantId = (int) app('tenant_id'); + + $baseQuery = Role::where('tenant_id', $tenantId) + ->where('guard_name', self::$guard); + + return [ + 'total' => (clone $baseQuery)->count(), + 'visible' => (clone $baseQuery)->where('is_hidden', false)->count(), + 'hidden' => (clone $baseQuery)->where('is_hidden', true)->count(), + 'with_users' => (clone $baseQuery)->has('users')->count(), + ]; + } + + /** 활성 역할 목록 (드롭다운용) */ + public static function active() + { + $tenantId = (int) app('tenant_id'); + + return Role::where('tenant_id', $tenantId) + ->where('guard_name', self::$guard) + ->where('is_hidden', false) + ->orderBy('name') + ->get(['id', 'name', 'description']); + } } diff --git a/database/migrations/2025_12_30_160802_add_is_hidden_to_roles_table.php b/database/migrations/2025_12_30_160802_add_is_hidden_to_roles_table.php new file mode 100644 index 0000000..f86ed19 --- /dev/null +++ b/database/migrations/2025_12_30_160802_add_is_hidden_to_roles_table.php @@ -0,0 +1,31 @@ +boolean('is_hidden') + ->default(false) + ->after('description') + ->comment('숨김 여부 (공개/숨김)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('is_hidden'); + }); + } +};