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:
parent
d024308c2e
commit
dcb5bd2790
@ -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,
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Role> 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,10 +142,18 @@ 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')
|
||||
@ -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,10 +258,18 @@ 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')
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -64,9 +64,14 @@ public function boot(AppRoleService $appRoleService, RoleService $roleService):
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->attempt(
|
||||
action: function () use ($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 ($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(
|
||||
|
||||
@ -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)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@php
|
||||
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
|
||||
@endphp
|
||||
@foreach($row->roles as $role)
|
||||
@if($role->priority > $userPriority)
|
||||
<x-mary-badge class="badge-soft badge-neutral" :value="ucfirst($role->name)"/>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endscope
|
||||
@ -223,9 +236,10 @@ public function saveRoles(): void
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@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)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@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)
|
||||
<x-mary-badge class="badge-soft badge-secondary" :value="$app->appName"/>
|
||||
@endforeach
|
||||
@ -258,6 +275,12 @@ 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)
|
||||
<div class="flex gap-1">
|
||||
<x-mary-button
|
||||
icon="lucide.shield-plus"
|
||||
@ -275,6 +298,7 @@ class="btn-sm btn-circle btn-soft"
|
||||
class="btn-sm btn-circle btn-error btn-soft"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
@endscope
|
||||
</x-mary-table>
|
||||
</x-shared.card>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user