Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions app/Http/Controllers/Api/V1/RoleManagementController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\CreateRoleRequest;
use App\Http\Requests\Api\V1\UpdateRoleRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Spatie\Permission\Models\Role;

class RoleManagementController extends Controller
{
/**
* List all roles with permission count and user count.
*/
public function index(): JsonResponse
{
Gate::authorize('viewAny', Role::class);

$roles = Role::withCount(['permissions', 'users'])
->orderBy('name')
->get();

return response()->json([
'data' => $roles->map(fn (Role $role) => [
'id' => $role->id,
'name' => $role->name,
'permissions_count' => $role->permissions_count,
'users_count' => $role->users_count,
'created_at' => $role->created_at?->toIso8601String() ?? '',
'updated_at' => $role->updated_at?->toIso8601String() ?? '',
]),
]);
}

/**
* Create a new role with permissions.
*/
public function store(CreateRoleRequest $request): JsonResponse
{
Gate::authorize('create', Role::class);

/** @var string $name */
$name = $request->input('name');
$role = Role::create(['name' => $name]);

/** @var array<int, string> $permissions */
$permissions = $request->input('permissions', []);
if (! empty($permissions)) {
$role->syncPermissions($permissions);
}

return response()->json([
'data' => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->fresh()?->permissions->pluck('name') ?? [],
'created_at' => $role->created_at?->toIso8601String() ?? '',
'updated_at' => $role->updated_at?->toIso8601String() ?? '',
],
], 201);
}

/**
* Get role details with assigned permissions.
*/
public function show(int $id): JsonResponse
{
$role = Role::with('permissions')->findOrFail($id);
Gate::authorize('view', $role);

return response()->json([
'data' => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->permissions->pluck('name'),
'users_count' => $role->users()->count(),
'created_at' => $role->created_at?->toIso8601String() ?? '',
'updated_at' => $role->updated_at?->toIso8601String() ?? '',
],
]);
}

/**
* Update role name and/or permissions.
*/
public function update(UpdateRoleRequest $request, int $id): JsonResponse
{
$role = Role::findOrFail($id);
Gate::authorize('update', $role);

if ($request->filled('name')) {
/** @var string $name */
$name = $request->input('name');
$role->name = $name;
$role->save();
}

if ($request->has('permissions')) {
/** @var array<int, string> $permissions */
$permissions = $request->input('permissions', []);
$role->syncPermissions($permissions);
}

$freshRole = $role->fresh();

return response()->json([
'data' => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $freshRole?->permissions->pluck('name') ?? [],
'created_at' => $role->created_at?->toIso8601String() ?? '',
'updated_at' => $role->updated_at?->toIso8601String() ?? '',
],
]);
}

/**
* Delete role (only if not assigned to any users).
*/
public function destroy(int $id): Response|JsonResponse
{
$role = Role::findOrFail($id);
Gate::authorize('delete', $role);

$usersCount = $role->users()->count();

if ($usersCount > 0) {
return response()->json([
'message' => 'Cannot delete role while assigned to users',
'assigned_to' => $usersCount,
], 422);
}

$role->delete();

return response()->noContent();
}
}
64 changes: 64 additions & 0 deletions app/Http/Requests/Api/V1/CreateRoleRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;
use Spatie\Permission\Models\Permission;

class CreateRoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by Gate in controller
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
'permissions' => ['nullable', 'array'],
'permissions.*' => [
'required',
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value)) {
$fail('The permission must be a valid string.');

return;
}
if (! Permission::where('name', $value)->exists()) {
$fail("The permission {$value} does not exist.");
}
},
],
];
}

/**
* Get custom error messages for validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => 'Role name is required.',
'name.unique' => 'A role with this name already exists.',
'permissions.*.required' => 'Each permission must be a valid string.',
];
}
}
72 changes: 72 additions & 0 deletions app/Http/Requests/Api/V1/UpdateRoleRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Permission;

class UpdateRoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by Gate in controller
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
$roleId = $this->route('id');

return [
'name' => [
'sometimes',
'required',
'string',
'max:255',
Rule::unique('roles', 'name')->ignore($roleId),
],
'permissions' => ['nullable', 'array'],
'permissions.*' => [
'required',
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value)) {
$fail('The permission must be a valid string.');

return;
}
if (! Permission::where('name', $value)->exists()) {
$fail("The permission {$value} does not exist.");
}
},
],
];
}

/**
* Get custom error messages for validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.unique' => 'A role with this name already exists.',
'permissions.*.required' => 'Each permission must be a valid string.',
];
}
}
55 changes: 55 additions & 0 deletions app/Policies/RoleManagementPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Policies;

use App\Models\User;
use Spatie\Permission\Models\Role;

class RoleManagementPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->can('roles.read');
}

/**
* Determine whether the user can view the model.
*/
public function view(User $user, Role $role): bool
{
return $user->can('roles.read');
}

/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->can('roles.create');
}

/**
* Determine whether the user can update the model.
*/
public function update(User $user, Role $role): bool
{
return $user->can('roles.update');
}

/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Role $role): bool
{
return $user->can('roles.delete');
}
}
6 changes: 6 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

use App\Models\Person;
use App\Observers\PersonObserver;
use App\Policies\RoleManagementPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Spatie\Permission\Models\Role;

class AppServiceProvider extends ServiceProvider
{
Expand All @@ -25,5 +28,8 @@ public function register(): void
public function boot(): void
{
Person::observe(PersonObserver::class);

// Register policy for Spatie Role model
Gate::policy(Role::class, RoleManagementPolicy::class);
}
}
8 changes: 8 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

use App\Http\Controllers\Api\V1\RoleManagementController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\PersonController;
use App\Http\Controllers\RoleController;
Expand Down Expand Up @@ -43,6 +44,13 @@
Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']);
Route::get('/me', [AuthController::class, 'me']);

// Role Management CRUD API
Route::get('/roles', [RoleManagementController::class, 'index']);
Route::post('/roles', [RoleManagementController::class, 'store']);
Route::get('/roles/{id}', [RoleManagementController::class, 'show']);
Route::patch('/roles/{id}', [RoleManagementController::class, 'update']);
Route::delete('/roles/{id}', [RoleManagementController::class, 'destroy']);

// Role management endpoints
Route::post('/users/{user}/roles', [RoleController::class, 'store'])
->middleware('permission:role.assign');
Expand Down
Loading