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.
This commit is contained in:
= 2026-05-25 10:12:42 +00:00
parent d024308c2e
commit dcb5bd2790
12 changed files with 180 additions and 44 deletions

View File

@ -15,15 +15,19 @@ public function __construct(
public int $id, public int $id,
public string $name, public string $name,
public ?ConnectedAppPermissonEnum $type, public ?ConnectedAppPermissonEnum $type,
public string $resource = 'general',
) {} ) {}
public static function fromModel(Permission $permission): self public static function fromModel(Permission $permission): self
{ {
$parts = explode(':', $permission->name);
$resource = count($parts) > 1 ? $parts[0] : 'general';
return new self( return new self(
id: $permission->id, id: $permission->id,
name: $permission->name, name: $permission->name,
type: AppPermission::type($permission->name), type: AppPermission::type($permission->name),
resource: $resource,
); );
} }
} }

View File

@ -5,15 +5,18 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions; use Spatie\Activitylog\Support\LogOptions;
use Spatie\Permission\Models\Role as SpatieRole; use Spatie\Permission\Models\{Permission, Role as SpatieRole};
/** /**
* @property int $priority * @property int $priority
* @property AppRole $pivot * @property AppRole $pivot
* @property ConnectedApp $apps * @property ConnectedApp $apps
*
* @method static Builder<Role> visible()
*/ */
#[Fillable(['priority'])] #[Fillable(['priority'])]
class Role extends SpatieRole class Role extends SpatieRole
@ -39,4 +42,28 @@ public function getActivitylogOptions(): LogOptions
->logOnlyDirty() ->logOnlyDirty()
->useLogName('role_management'); ->useLogName('role_management');
} }
/**
* Override the Spatie permissions relationship to provide precise type-hinting for Larastan.
*
* @return BelongsToMany<Permission, Role>
*/
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);
}
} }

View File

@ -142,11 +142,19 @@ public function getAppsForRole(int $roleId): DataCollection
#[NoDiscard] #[NoDiscard]
public function searchAppsForSelect(string $search = ''): Collection public function searchAppsForSelect(string $search = ''): Collection
{ {
return ConnectedApp::query() $query = ConnectedApp::query()
->select(['id', 'name']) ->select(['id', 'name']);
->when($search, function ($query, $search): void {
$query->where('name', 'like', '%'.$search.'%'); 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 ->limit(50) // Limit the results so the dropdown doesn't lag
->orderBy('name') ->orderBy('name')
->get(['id', 'name']); ->get(['id', 'name']);
@ -160,7 +168,16 @@ public function searchAppsForSelect(string $search = ''): Collection
#[NoDiscard] #[NoDiscard]
public function getAllAppIds(): array 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] #[NoDiscard]
public function searchUsersForSelect(string $search = ''): DataCollection public function searchUsersForSelect(string $search = ''): DataCollection
{ {
$users = User::query() $query = User::query()
->select(['id', 'name']) ->select(['id', 'name']);
->when($search, function ($query, $search): void {
$query->where('name', 'like', '%'.$search.'%'); 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) ->limit(50)
->orderBy('name') ->orderBy('name')
->get(); ->get();

View File

@ -56,6 +56,7 @@ public function delete(int $id): void
public function getAll(): PaginatedDataCollection public function getAll(): PaginatedDataCollection
{ {
$roles = Role::query() $roles = Role::query()
->visible()
->with(['apps:id,name', 'users:id,name']) ->with(['apps:id,name', 'users:id,name'])
->orderBy('id') ->orderBy('id')
->paginate(); ->paginate();
@ -66,6 +67,7 @@ public function getAll(): PaginatedDataCollection
public function searchRolesForSelect(string $search = ''): Collection public function searchRolesForSelect(string $search = ''): Collection
{ {
return Role::query() return Role::query()
->visible()
->when('' !== $search, fn ($query) => $query->where('name', 'like', "%{$search}%")) ->when('' !== $search, fn ($query) => $query->where('name', 'like', "%{$search}%"))
->select('id', 'name') ->select('id', 'name')
->get(); ->get();
@ -73,7 +75,7 @@ public function searchRolesForSelect(string $search = ''): Collection
public function getAllRoleIds(): array 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() $roles = Role::query()
->visible()
->with('permissions:id,name') ->with('permissions:id,name')
->whereIn('id', $roleIds) ->whereIn('id', $roleIds)
->get(['id', 'name', 'priority']); ->get(['id', 'name', 'priority']);

View File

@ -25,7 +25,7 @@ public function getAll(): PaginatedDataCollection
{ {
$users = User::query() $users = User::query()
->with([ ->with([
'roles:id,name', 'roles:id,name,priority',
'roles.apps:id,name', 'roles.apps:id,name',
'roles.permissions:id,name', 'roles.permissions:id,name',
'restrictedPermissions', 'restrictedPermissions',
@ -64,7 +64,19 @@ public function assignRoles(int $userId, array $roleIds): void
DB::transaction(function () use ($userId, $roleIds): void { DB::transaction(function () use ($userId, $roleIds): void {
$user = User::query()->findOrFail($userId); $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 { 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();
}); });
} }
} }

View File

@ -15,8 +15,10 @@ class DefaultRoleWithPermissionSeeder extends Seeder
public function run(): void public function run(): void
{ {
$role = Role::findOrCreate(DefaultUserRole::Admin->value); $role = Role::findOrCreate(DefaultUserRole::Admin->value);
$role->update(['priority' => 0]);
$role->givePermissionTo(Permission::all()); $role->givePermissionTo(Permission::all());
$role = Role::findOrCreate(DefaultUserRole::User->value); $role = Role::findOrCreate(DefaultUserRole::User->value);
$role->update(['priority' => 100]);
$this->syncUserPermissions($role); $this->syncUserPermissions($role);
} }

View File

@ -16,7 +16,7 @@ public function run(): void
$allEnums = $enumExtractor->extract(); $allEnums = $enumExtractor->extract();
foreach ($allEnums as $enumCases) { foreach ($allEnums as $enumCases) {
foreach ($enumCases as $case) { foreach ($enumCases as $case) {
Permission::create(['name' => $case->value]); Permission::findOrCreate($case->value);
} }
} }
} }

View File

@ -2,7 +2,7 @@
use App\Data\Ui\Sidebar\NavItemData; use App\Data\Ui\Sidebar\NavItemData;
use App\Data\Ui\Sidebar\NavSubItemData; 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 Livewire\Component;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -65,6 +65,12 @@ public function navItems(): DataCollection
active_pattern: 'access-manager.roles-and-apps.*', active_pattern: 'access-manager.roles-and-apps.*',
permission: RoleAndAppsPermissionEnum::Access permission: RoleAndAppsPermissionEnum::Access
), ),
new NavSubItemData(
label: 'Permission Manager',
route: 'access-manager.permissions.index',
active_pattern: 'access-manager.permissions.*',
permission: PermissionPermissionEnum::Access
),
new NavSubItemData( new NavSubItemData(
label: 'Users', label: 'Users',
route: 'access-manager.users.index', route: 'access-manager.users.index',

View File

@ -83,7 +83,15 @@ public function openCreateModal(): void
public function save(): void public function save(): void
{ {
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$this->form->validate(); $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( $this->attempt(
action: function (): void { action: function (): void {
$role = $this->roleService->create(new CreateRoleData( $role = $this->roleService->create(new CreateRoleData(

View File

@ -64,9 +64,14 @@ public function boot(AppRoleService $appRoleService, RoleService $roleService):
public function mount(int $id): void 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( $this->attempt(
action: function () use ($id): void { action: function () use ($role): void {
$role = Role::query()->findOrFail($id);
$this->roleId = $role->id; $this->roleId = $role->id;
$this->roleName = $role->name; $this->roleName = $role->name;
$this->rolePriority = $role->priority; $this->rolePriority = $role->priority;
@ -277,8 +282,12 @@ public function openEditPriorityModal(): void
public function savePriority(): void public function savePriority(): void
{ {
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$this->validate([ $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( $this->attempt(

View File

@ -68,10 +68,18 @@ public function openAssignRolesModal(int $userId): void
{ {
$this->attempt( $this->attempt(
action: function () use ($userId) { 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->currentUserId = $userId;
$this->form->reset(); $this->form->reset();
$this->searchRoles(); $this->searchRoles();
$this->form->roleIds = User::findOrFail($userId)->roles()->pluck('id')->toArray(); $this->form->roleIds = $user->roles()->pluck('id')->toArray();
$this->hydratePermissionInForm(); $this->hydratePermissionInForm();
$this->showAssignRolesModal = true; $this->showAssignRolesModal = true;
}, },
@ -213,8 +221,13 @@ public function saveRoles(): void
@scope('cell_roles', $row) @scope('cell_roles', $row)
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@php
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp
@foreach($row->roles as $role) @foreach($row->roles as $role)
<x-mary-badge class="badge-soft badge-neutral" :value="ucfirst($role->name)"/> @if($role->priority > $userPriority)
<x-mary-badge class="badge-soft badge-neutral" :value="ucfirst($role->name)"/>
@endif
@endforeach @endforeach
</div> </div>
@endscope @endscope
@ -223,9 +236,10 @@ public function saveRoles(): void
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@php @php
$displayedPermissions = []; $displayedPermissions = [];
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp @endphp
@foreach($row->roles as $role) @foreach($row->roles as $role)
@if($role->permissions) @if($role->priority > $userPriority && $role->permissions)
@foreach($role->permissions as $permission) @foreach($role->permissions as $permission)
@if(!in_array($permission->id, $displayedPermissions)) @if(!in_array($permission->id, $displayedPermissions))
@php @php
@ -247,8 +261,11 @@ public function saveRoles(): void
@scope('cell_apps', $row) @scope('cell_apps', $row)
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@php
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp
@foreach($row->roles as $role) @foreach($row->roles as $role)
@if($role->apps) @if($role->priority > $userPriority && $role->apps)
@foreach ($role->apps as $app) @foreach ($role->apps as $app)
<x-mary-badge class="badge-soft badge-secondary" :value="$app->appName"/> <x-mary-badge class="badge-soft badge-secondary" :value="$app->appName"/>
@endforeach @endforeach
@ -258,23 +275,30 @@ public function saveRoles(): void
@endscope @endscope
@scope('actions', $row) @scope('actions', $row)
<div class="flex gap-1"> @php
<x-mary-button $userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
icon="lucide.shield-plus" $rowMinPriority = collect($row->roles)->min('priority') ?? 999999;
wire:click="openAssignRolesModal({{ $row->id }})" $isManageable = $rowMinPriority > $userPriority;
tooltip="Manage roles" @endphp
class="btn-sm btn-circle btn-soft" @if($isManageable)
spinner="openAssignRolesModal({{ $row->id }})" <div class="flex gap-1">
/> <x-mary-button
<x-mary-button icon="lucide.shield-plus"
icon="lucide.trash" wire:click="openAssignRolesModal({{ $row->id }})"
wire:click="requireConfirmation('delete', {{ $row->id }});" tooltip="Manage roles"
spinner="requireConfirmation('delete', {{ $row->id }});" class="btn-sm btn-circle btn-soft"
variant="danger" spinner="openAssignRolesModal({{ $row->id }})"
:tooltip="__('Delete user')" />
class="btn-sm btn-circle btn-error btn-soft" <x-mary-button
/> icon="lucide.trash"
</div> wire:click="requireConfirmation('delete', {{ $row->id }});"
spinner="requireConfirmation('delete', {{ $row->id }});"
variant="danger"
:tooltip="__('Delete user')"
class="btn-sm btn-circle btn-error btn-soft"
/>
</div>
@endif
@endscope @endscope
</x-mary-table> </x-mary-table>
</x-shared.card> </x-shared.card>

View File

@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Enums\DefaultUserRole; 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 App\Http\Controllers\ResolveDashboardRouteController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
@ -12,6 +12,7 @@
Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::group(['middleware' => ['auth', 'verified']], function (): void {
Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard');
Route::prefix('dashboard')->name('dashboard.')->group(function (): void { Route::prefix('dashboard')->name('dashboard.')->group(function (): void {
Route::livewire('/user', 'pages::dashboards.user') Route::livewire('/user', 'pages::dashboards.user')
->middleware(RoleMiddleware::using(DefaultUserRole::User)) ->middleware(RoleMiddleware::using(DefaultUserRole::User))
@ -34,6 +35,12 @@
->group(function (): void { ->group(function (): void {
Route::livewire('users', 'pages::access-manager.users.index')->name('index'); 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 { Route::prefix('apps')->name('apps.')->group(function (): void {