From 2a1994063580fc556374b91ee9ca89f395f0a3f2 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 25 May 2026 10:12:42 +0000 Subject: [PATCH] Feat: implement role-based priority restrictions and permission manager - Added `priority`-based access logic to restrict role and user management actions. - Introduced `Permission Manager` UI and routes for permission access control. - Updated `RoleService` and `AppRoleService` to include `visible` scope for priority filtering. - Enhanced seeding to assign priorities to default roles and ensure proper hierarchy. - Updated sidebar, routes, and data structure to handle new permission enums and resource segmentation. - Improved UI components to dynamically restrict visibility and actions based on role priority. --- app/Data/Permissions/PermissionData.php | 6 +- .../Permissions/PermissionPermissionEnum.php | 14 ++ app/Models/Role.php | 29 ++- app/Services/AppRoleService.php | 47 +++- app/Services/PermissionManagerService.php | 64 ++++++ app/Services/RoleService.php | 5 +- app/Services/UserService.php | 27 ++- .../DefaultRoleWithPermissionSeeder.php | 2 + database/seeders/PermissionSeeder.php | 2 +- .../components/dashboard-sidebar.blade.php | 8 +- .../permissions/⚡index.blade.php | 214 ++++++++++++++++++ .../roles-and-apps/⚡index.blade.php | 8 + .../roles-and-apps/⚡show.blade.php | 15 +- .../access-manager/users/⚡index.blade.php | 66 ++++-- routes/web.php | 9 +- 15 files changed, 472 insertions(+), 44 deletions(-) create mode 100644 app/Enums/Permissions/PermissionPermissionEnum.php create mode 100644 app/Services/PermissionManagerService.php create mode 100644 resources/views/pages/access-manager/permissions/⚡index.blade.php diff --git a/app/Data/Permissions/PermissionData.php b/app/Data/Permissions/PermissionData.php index 6081afa..559b166 100644 --- a/app/Data/Permissions/PermissionData.php +++ b/app/Data/Permissions/PermissionData.php @@ -15,15 +15,19 @@ public function __construct( public int $id, public string $name, public ?ConnectedAppPermissonEnum $type, + public string $resource = 'general', ) {} public static function fromModel(Permission $permission): self { + $parts = explode(':', $permission->name); + $resource = count($parts) > 1 ? $parts[0] : 'general'; + return new self( id: $permission->id, name: $permission->name, type: AppPermission::type($permission->name), + resource: $resource, ); - } } diff --git a/app/Enums/Permissions/PermissionPermissionEnum.php b/app/Enums/Permissions/PermissionPermissionEnum.php new file mode 100644 index 0000000..208a183 --- /dev/null +++ b/app/Enums/Permissions/PermissionPermissionEnum.php @@ -0,0 +1,14 @@ + visible() */ #[Fillable(['priority'])] class Role extends SpatieRole @@ -39,4 +42,28 @@ public function getActivitylogOptions(): LogOptions ->logOnlyDirty() ->useLogName('role_management'); } + + /** + * Override the Spatie permissions relationship to provide precise type-hinting for Larastan. + * + * @return BelongsToMany + */ + public function permissions(): BelongsToMany + { + return parent::permissions(); + } + + /** + * Scope a query to only include roles with priority strictly greater than the logged in user's priority (lower privilege). + */ + public function scopeVisible(Builder $query): Builder + { + if (! auth()->check()) { + return $query; + } + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + + return $query->where('priority', '>', $userPriority); + } } diff --git a/app/Services/AppRoleService.php b/app/Services/AppRoleService.php index 2733ec2..50d561a 100644 --- a/app/Services/AppRoleService.php +++ b/app/Services/AppRoleService.php @@ -142,11 +142,19 @@ public function getAppsForRole(int $roleId): DataCollection #[NoDiscard] public function searchAppsForSelect(string $search = ''): Collection { - return ConnectedApp::query() - ->select(['id', 'name']) - ->when($search, function ($query, $search): void { - $query->where('name', 'like', '%'.$search.'%'); - }) + $query = ConnectedApp::query() + ->select(['id', 'name']); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereHas('roles', function ($q) use ($userPriority): void { + $q->where('priority', '>', $userPriority); + }); + } + + return $query->when($search, function ($q, $search): void { + $q->where('name', 'like', '%'.$search.'%'); + }) ->limit(50) // Limit the results so the dropdown doesn't lag ->orderBy('name') ->get(['id', 'name']); @@ -160,7 +168,16 @@ public function searchAppsForSelect(string $search = ''): Collection #[NoDiscard] public function getAllAppIds(): array { - return ConnectedApp::pluck('id')->toArray(); + $query = ConnectedApp::query(); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereHas('roles', function ($q) use ($userPriority): void { + $q->where('priority', '>', $userPriority); + }); + } + + return $query->pluck('id')->toArray(); } /** @@ -241,11 +258,19 @@ public function getUsersForRole(int $roleId): DataCollection #[NoDiscard] public function searchUsersForSelect(string $search = ''): DataCollection { - $users = User::query() - ->select(['id', 'name']) - ->when($search, function ($query, $search): void { - $query->where('name', 'like', '%'.$search.'%'); - }) + $query = User::query() + ->select(['id', 'name']); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereDoesntHave('roles', function ($q) use ($userPriority): void { + $q->where('priority', '<=', $userPriority); + }); + } + + $users = $query->when($search, function ($q, $search): void { + $q->where('name', 'like', '%'.$search.'%'); + }) ->limit(50) ->orderBy('name') ->get(); diff --git a/app/Services/PermissionManagerService.php b/app/Services/PermissionManagerService.php new file mode 100644 index 0000000..4a7067d --- /dev/null +++ b/app/Services/PermissionManagerService.php @@ -0,0 +1,64 @@ + + */ + #[NoDiscard] + public function getRolesWithPermissionsAndUsers(): PaginatedDataCollection + { + $roles = Role::query() + ->visible() + ->with(['permissions:id,name', 'users:id,name']) + ->orderBy('priority', 'asc') + ->paginate(); + + return RoleData::collect($roles, PaginatedDataCollection::class); + } + + /** + * Get all non-app system permissions mapped to the PermissionData DTO. + * + * @return DataCollection + */ + #[NoDiscard] + public function getNonAppPermissions(): DataCollection + { + $permissions = Permission::all() + ->filter(fn ($p) => null === AppPermission::type($p->name)); + + return PermissionData::collect($permissions, DataCollection::class); + } + + /** + * Get a role by its ID, ensuring it has a priority strictly greater than the logged in user's priority. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function getRoleForEditing(int $roleId): Role + { + $role = Role::query()->findOrFail($roleId); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + if ($role->priority <= $userPriority) { + abort(403, 'Unauthorized to manage permissions for this role.'); + } + + return $role; + } +} diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php index 6113c21..7ef3352 100644 --- a/app/Services/RoleService.php +++ b/app/Services/RoleService.php @@ -56,6 +56,7 @@ public function delete(int $id): void public function getAll(): PaginatedDataCollection { $roles = Role::query() + ->visible() ->with(['apps:id,name', 'users:id,name']) ->orderBy('id') ->paginate(); @@ -66,6 +67,7 @@ public function getAll(): PaginatedDataCollection public function searchRolesForSelect(string $search = ''): Collection { return Role::query() + ->visible() ->when('' !== $search, fn ($query) => $query->where('name', 'like', "%{$search}%")) ->select('id', 'name') ->get(); @@ -73,7 +75,7 @@ public function searchRolesForSelect(string $search = ''): Collection public function getAllRoleIds(): array { - return Role::query()->pluck('id')->toArray(); + return Role::query()->visible()->pluck('id')->toArray(); } /** @@ -92,6 +94,7 @@ public function getPermissionsGroupedByRole(array $roleIds): ?DataCollection } $roles = Role::query() + ->visible() ->with('permissions:id,name') ->whereIn('id', $roleIds) ->get(['id', 'name', 'priority']); diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 81d8944..4eb3367 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -25,7 +25,7 @@ public function getAll(): PaginatedDataCollection { $users = User::query() ->with([ - 'roles:id,name', + 'roles:id,name,priority', 'roles.apps:id,name', 'roles.permissions:id,name', 'restrictedPermissions', @@ -64,7 +64,19 @@ public function assignRoles(int $userId, array $roleIds): void DB::transaction(function () use ($userId, $roleIds): void { $user = User::query()->findOrFail($userId); - $user->syncRoles($roleIds); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + + // Get user's existing roles that have priority <= $userPriority (which are forbidden to the logged in user) + $forbiddenRoleIds = $user->roles() + ->where('priority', '<=', $userPriority) + ->pluck('id') + ->toArray(); + + // Merge them with the newly assigned roles so they are preserved + $mergedRoleIds = array_values(array_unique(array_merge($forbiddenRoleIds, $roleIds))); + + $user->syncRoles($mergedRoleIds); }); } @@ -111,7 +123,16 @@ public function delete(int $id): void } DB::transaction(function () use ($id): void { - User::query()->findOrFail($id)->delete(); + $user = User::query()->findOrFail($id); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $targetUserMinPriority = $user->roles()->min('priority') ?? 999999; + + if ($targetUserMinPriority <= $userPriority) { + abort(403, 'Unauthorized to delete this user due to role priority hierarchy.'); + } + + $user->delete(); }); } } diff --git a/database/seeders/DefaultRoleWithPermissionSeeder.php b/database/seeders/DefaultRoleWithPermissionSeeder.php index 7d28392..e84be0c 100644 --- a/database/seeders/DefaultRoleWithPermissionSeeder.php +++ b/database/seeders/DefaultRoleWithPermissionSeeder.php @@ -15,8 +15,10 @@ class DefaultRoleWithPermissionSeeder extends Seeder public function run(): void { $role = Role::findOrCreate(DefaultUserRole::Admin->value); + $role->update(['priority' => 0]); $role->givePermissionTo(Permission::all()); $role = Role::findOrCreate(DefaultUserRole::User->value); + $role->update(['priority' => 100]); $this->syncUserPermissions($role); } diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 1eb8ec0..55d5ff5 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -16,7 +16,7 @@ public function run(): void $allEnums = $enumExtractor->extract(); foreach ($allEnums as $enumCases) { foreach ($enumCases as $case) { - Permission::create(['name' => $case->value]); + Permission::findOrCreate($case->value); } } } diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index c243d0a..99cd5b1 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -2,7 +2,7 @@ use App\Data\Ui\Sidebar\NavItemData; use App\Data\Ui\Sidebar\NavSubItemData; -use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, ProfilePermissionEnum, RoleAndAppsPermissionEnum, SecurityPermissionEnum, UserPermissionEnum}; +use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, ProfilePermissionEnum, RoleAndAppsPermissionEnum, SecurityPermissionEnum, UserPermissionEnum, PermissionPermissionEnum}; use Livewire\Component; use Spatie\LaravelData\DataCollection; @@ -65,6 +65,12 @@ public function navItems(): DataCollection active_pattern: 'access-manager.roles-and-apps.*', permission: RoleAndAppsPermissionEnum::Access ), + new NavSubItemData( + label: 'Permission Manager', + route: 'access-manager.permissions.index', + active_pattern: 'access-manager.permissions.*', + permission: PermissionPermissionEnum::Access + ), new NavSubItemData( label: 'Users', route: 'access-manager.users.index', diff --git a/resources/views/pages/access-manager/permissions/⚡index.blade.php b/resources/views/pages/access-manager/permissions/⚡index.blade.php new file mode 100644 index 0000000..a3417dd --- /dev/null +++ b/resources/views/pages/access-manager/permissions/⚡index.blade.php @@ -0,0 +1,214 @@ +permissionManagerService = $permissionManagerService; + } + + public function mount(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Role'), + new TableHeader(key: 'priority', label: 'Priority'), + new TableHeader(key: 'permissions', label: 'Permissions'), + new TableHeader(key: 'users', label: 'Added Users'), + ], DataCollection::class); + } + + /** + * @return PaginatedDataCollection + */ + #[Computed] + public function getRoles(): PaginatedDataCollection + { + return $this->permissionManagerService->getRolesWithPermissionsAndUsers(); + } + + public function openEditModal(int $roleId): void + { + $this->authorize(PermissionPermissionEnum::Update->value); + + $this->attempt( + action: function () use ($roleId): void { + $role = $this->permissionManagerService->getRoleForEditing($roleId); + + $this->roleId = $role->id; + $this->roleName = $role->name; + + $this->nonAppPermissions = $this->permissionManagerService->getNonAppPermissions()->toCollection() + ->groupBy('resource') + ->map(fn($group) => $group->toArray()) + ->toArray(); + + $this->selectedPermissionIds = $role->permissions() + ->pluck('id') + ->map(fn($id) => (int) $id) + ->toArray(); + + $this->showEditModal = true; + }, + showSuccess: false, + ); + } + + public function savePermissions(): void + { + $this->authorize(PermissionPermissionEnum::Update->value); + + $this->attempt( + action: function (): void { + $role = $this->permissionManagerService->getRoleForEditing($this->roleId); + + $existingAppPermissions = $role->permissions() + ->get() + ->filter(fn($p) => AppPermission::type($p->name) !== null) + ->pluck('name') + ->toArray(); + + $selectedNames = Permission::query() + ->whereIn('id', $this->selectedPermissionIds) + ->pluck('name') + ->toArray(); + + $finalNames = array_values(array_unique(array_merge($existingAppPermissions, $selectedNames))); + $role->syncPermissions($finalNames); + + $this->showEditModal = false; + unset($this->getRoles); + }, + successMessage: 'Permissions updated successfully!', + ); + } +}; +?> + +
+ + + @scope('cell_name', $row) + {{ $row->name }} + @endscope + + @scope('cell_priority', $row) + {{ $row->priority }} + @endscope + + @scope('cell_permissions', $row) +
+ @if($row->permissions && $row->permissions->count()) + @foreach($row->permissions as $permission) + @if(AppPermission::type($permission->name) === null) + + @else + + @endif + @endforeach + @else + No permissions + @endif +
+ @endscope + + @scope('cell_users', $row) +
+ @if($row->users && $row->users->count()) + @foreach($row->users as $user) + + @endforeach + @else + No users + @endif +
+ @endscope + + @scope('actions', $row) + @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $isManageable = $row->priority > $userPriority; + @endphp + @if($isManageable) +
+ @can(PermissionPermissionEnum::Update->value) + + @endcan +
+ @endif + @endscope +
+
+ + + +
+ @foreach ($nonAppPermissions as $resource => $permissions) +
+

+ {{ ucwords(str_replace('-', ' ', $resource)) }} +

+
+ @foreach ($permissions as $permission) + + @endforeach +
+
+ @endforeach +
+ + + + Cancel + + + Save + + +
+
+
diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php index d5ebc9e..38e612e 100644 --- a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php +++ b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php @@ -83,7 +83,15 @@ public function openCreateModal(): void public function save(): void { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $this->form->validate(); + + if ((int) $this->form->priority <= $userPriority) { + $this->addError('form.priority', 'The priority must be greater than your own highest role priority (' . $userPriority . ').'); + return; + } + $this->attempt( action: function (): void { $role = $this->roleService->create(new CreateRoleData( diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php index a25fdb6..292cb00 100644 --- a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php +++ b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php @@ -64,9 +64,14 @@ public function boot(AppRoleService $appRoleService, RoleService $roleService): public function mount(int $id): void { + $role = Role::query()->findOrFail($id); + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + if ($role->priority <= $userPriority) { + abort(403, 'Unauthorized access to this role.'); + } + $this->attempt( - action: function () use ($id): void { - $role = Role::query()->findOrFail($id); + action: function () use ($role): void { $this->roleId = $role->id; $this->roleName = $role->name; $this->rolePriority = $role->priority; @@ -277,8 +282,12 @@ public function openEditPriorityModal(): void public function savePriority(): void { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $this->validate([ - 'newPriority' => 'required|integer|min:0', + 'newPriority' => 'required|integer|min:' . ($userPriority + 1), + ], [ + 'newPriority.min' => 'The priority must be greater than your own highest role priority (' . $userPriority . ').', ]); $this->attempt( diff --git a/resources/views/pages/access-manager/users/⚡index.blade.php b/resources/views/pages/access-manager/users/⚡index.blade.php index 4e92e97..953453a 100644 --- a/resources/views/pages/access-manager/users/⚡index.blade.php +++ b/resources/views/pages/access-manager/users/⚡index.blade.php @@ -68,10 +68,18 @@ public function openAssignRolesModal(int $userId): void { $this->attempt( action: function () use ($userId) { + $user = User::findOrFail($userId); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $targetUserMinPriority = $user->roles()->min('priority') ?? 999999; + if ($targetUserMinPriority <= $userPriority) { + abort(403, 'Unauthorized to manage this user.'); + } + $this->currentUserId = $userId; $this->form->reset(); $this->searchRoles(); - $this->form->roleIds = User::findOrFail($userId)->roles()->pluck('id')->toArray(); + $this->form->roleIds = $user->roles()->pluck('id')->toArray(); $this->hydratePermissionInForm(); $this->showAssignRolesModal = true; }, @@ -213,8 +221,13 @@ public function saveRoles(): void @scope('cell_roles', $row)
+ @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + @endphp @foreach($row->roles as $role) - + @if($role->priority > $userPriority) + + @endif @endforeach
@endscope @@ -223,9 +236,10 @@ public function saveRoles(): void
@php $displayedPermissions = []; + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; @endphp @foreach($row->roles as $role) - @if($role->permissions) + @if($role->priority > $userPriority && $role->permissions) @foreach($role->permissions as $permission) @if(!in_array($permission->id, $displayedPermissions)) @php @@ -247,8 +261,11 @@ public function saveRoles(): void @scope('cell_apps', $row)
+ @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + @endphp @foreach($row->roles as $role) - @if($role->apps) + @if($role->priority > $userPriority && $role->apps) @foreach ($role->apps as $app) @endforeach @@ -258,23 +275,30 @@ public function saveRoles(): void @endscope @scope('actions', $row) -
- - -
+ @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $rowMinPriority = collect($row->roles)->min('priority') ?? 999999; + $isManageable = $rowMinPriority > $userPriority; + @endphp + @if($isManageable) +
+ + +
+ @endif @endscope diff --git a/routes/web.php b/routes/web.php index 6f74fba..eea6546 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Enums\DefaultUserRole; -use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; +use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; use App\Http\Controllers\ResolveDashboardRouteController; use Illuminate\Support\Facades\Route; use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; @@ -12,6 +12,7 @@ Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); + Route::prefix('dashboard')->name('dashboard.')->group(function (): void { Route::livewire('/user', 'pages::dashboards.user') ->middleware(RoleMiddleware::using(DefaultUserRole::User)) @@ -34,6 +35,12 @@ ->group(function (): void { Route::livewire('users', 'pages::access-manager.users.index')->name('index'); }); + + Route::prefix('permissions')->name('permissions.') + ->middleware(PermissionMiddleware::using(PermissionPermissionEnum::Access)) + ->group(function (): void { + Route::livewire('/', 'pages::access-manager.permissions.index')->name('index'); + }); }); Route::prefix('apps')->name('apps.')->group(function (): void {