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/Models/Role.php b/app/Models/Role.php index 223497f..00d613b 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -5,15 +5,18 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Support\LogOptions; -use Spatie\Permission\Models\Role as SpatieRole; +use Spatie\Permission\Models\{Permission, Role as SpatieRole}; /** * @property int $priority * @property AppRole $pivot * @property ConnectedApp $apps + * + * @method static Builder 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/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/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 {