diff --git a/app/Http/Controllers/Api/V1/RoleManagementController.php b/app/Http/Controllers/Api/V1/RoleManagementController.php new file mode 100644 index 0000000..a7360f9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/RoleManagementController.php @@ -0,0 +1,147 @@ +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 $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 $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(); + } +} diff --git a/app/Http/Requests/Api/V1/CreateRoleRequest.php b/app/Http/Requests/Api/V1/CreateRoleRequest.php new file mode 100644 index 0000000..859d002 --- /dev/null +++ b/app/Http/Requests/Api/V1/CreateRoleRequest.php @@ -0,0 +1,64 @@ +> + */ + 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 + */ + 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.', + ]; + } +} diff --git a/app/Http/Requests/Api/V1/UpdateRoleRequest.php b/app/Http/Requests/Api/V1/UpdateRoleRequest.php new file mode 100644 index 0000000..6af0d80 --- /dev/null +++ b/app/Http/Requests/Api/V1/UpdateRoleRequest.php @@ -0,0 +1,72 @@ +> + */ + 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 + */ + public function messages(): array + { + return [ + 'name.unique' => 'A role with this name already exists.', + 'permissions.*.required' => 'Each permission must be a valid string.', + ]; + } +} diff --git a/app/Policies/RoleManagementPolicy.php b/app/Policies/RoleManagementPolicy.php new file mode 100644 index 0000000..e800246 --- /dev/null +++ b/app/Policies/RoleManagementPolicy.php @@ -0,0 +1,55 @@ +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'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 48b17d6..152174c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 { @@ -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); } } diff --git a/routes/api.php b/routes/api.php index 70837f3..fa58b22 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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; @@ -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'); diff --git a/tests/Feature/RoleManagementApiTest.php b/tests/Feature/RoleManagementApiTest.php new file mode 100644 index 0000000..383b8b0 --- /dev/null +++ b/tests/Feature/RoleManagementApiTest.php @@ -0,0 +1,380 @@ +tenant = TenantKey::create($keys); + + // Set tenant context for permission system + $this->registrar = app(PermissionRegistrar::class); + $this->registrar->setPermissionsTeamId($this->tenant->id); + + // Create test user with token + $this->user = User::factory()->create(); + $this->token = $this->user->createToken('test-device')->plainTextToken; + + // Create permissions for role management + Permission::create(['name' => 'roles.read', 'guard_name' => 'sanctum']); + Permission::create(['name' => 'roles.create', 'guard_name' => 'sanctum']); + Permission::create(['name' => 'roles.update', 'guard_name' => 'sanctum']); + Permission::create(['name' => 'roles.delete', 'guard_name' => 'sanctum']); + + // Create test permissions for assignment + Permission::create(['name' => 'employees.read', 'guard_name' => 'sanctum']); + Permission::create(['name' => 'employees.create', 'guard_name' => 'sanctum']); + Permission::create(['name' => 'shifts.read', 'guard_name' => 'sanctum']); +}); + +afterEach(function (): void { + $this->registrar->setPermissionsTeamId(null); + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +describe('GET /v1/roles - List Roles', function () { + test('returns 401 when not authenticated', function (): void { + $response = $this->getJson('/api/v1/roles'); + + $response->assertUnauthorized(); + }); + + test('returns 403 when user lacks roles.read permission', function (): void { + // User has no permissions + + $response = $this->withToken($this->token) + ->getJson('/api/v1/roles'); + + $response->assertForbidden(); + }); + + test('returns empty list when no roles exist', function (): void { + $this->user->givePermissionTo('roles.read'); + + $response = $this->withToken($this->token) + ->getJson('/api/v1/roles'); + + $response->assertOk() + ->assertJsonStructure(['data']) + ->assertJson(['data' => []]); + }); + + test('returns list of all roles with permission count', function (): void { + $this->user->givePermissionTo('roles.read'); + + // Create test roles + $manager = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + $guard = Role::create(['name' => 'Guard', 'guard_name' => 'sanctum']); + + // Assign permissions + $manager->givePermissionTo(['employees.read', 'employees.create', 'shifts.read']); + $guard->givePermissionTo('shifts.read'); + + $response = $this->withToken($this->token) + ->getJson('/api/v1/roles'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'name', 'permissions_count', 'users_count', 'created_at', 'updated_at'], + ], + ]) + ->assertJsonFragment(['name' => 'Manager', 'permissions_count' => 3]) + ->assertJsonFragment(['name' => 'Guard', 'permissions_count' => 1]); + }); +}); + +describe('POST /v1/roles - Create Role', function () { + test('returns 401 when not authenticated', function (): void { + $response = $this->postJson('/api/v1/roles', [ + 'name' => 'Regional Manager', + 'permissions' => ['employees.read'], + ]); + + $response->assertUnauthorized(); + }); + + test('returns 403 when user lacks roles.create permission', function (): void { + $this->user->givePermissionTo('roles.read'); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'name' => 'Regional Manager', + 'permissions' => ['employees.read'], + ]); + + $response->assertForbidden(); + }); + + test('returns 422 when name is missing', function (): void { + $this->user->givePermissionTo('roles.create'); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'permissions' => ['employees.read'], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + }); + + test('returns 422 when name already exists', function (): void { + $this->user->givePermissionTo('roles.create'); + Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'name' => 'Manager', + 'permissions' => ['employees.read'], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + }); + + test('returns 422 when permissions array contains non-existent permission', function (): void { + $this->user->givePermissionTo('roles.create'); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'name' => 'Regional Manager', + 'permissions' => ['non.existent.permission'], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['permissions.0']); + }); + + test('creates role with permissions successfully', function (): void { + $this->user->givePermissionTo('roles.create'); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'name' => 'Regional Manager', + 'permissions' => ['employees.read', 'shifts.read'], + ]); + + $response->assertCreated() + ->assertJsonStructure([ + 'data' => ['id', 'name', 'permissions', 'created_at', 'updated_at'], + ]) + ->assertJsonFragment(['name' => 'Regional Manager']) + ->assertJsonPath('data.permissions', fn ($permissions) => count($permissions) === 2); + + expect(Role::where('name', 'Regional Manager')->exists())->toBeTrue(); + }); + + test('creates role without permissions when permissions array is empty', function (): void { + $this->user->givePermissionTo('roles.create'); + + $response = $this->withToken($this->token) + ->postJson('/api/v1/roles', [ + 'name' => 'Empty Role', + 'permissions' => [], + ]); + + $response->assertCreated() + ->assertJsonPath('data.permissions', []); + }); +}); + +describe('GET /v1/roles/{id} - Get Role Details', function () { + test('returns 401 when not authenticated', function (): void { + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->getJson("/api/v1/roles/{$role->id}"); + + $response->assertUnauthorized(); + }); + + test('returns 403 when user lacks roles.read permission', function (): void { + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/roles/{$role->id}"); + + $response->assertForbidden(); + }); + + test('returns 404 when role does not exist', function (): void { + $this->user->givePermissionTo('roles.read'); + + $response = $this->withToken($this->token) + ->getJson('/api/v1/roles/999999'); + + $response->assertNotFound(); + }); + + test('returns role details with permissions', function (): void { + $this->user->givePermissionTo('roles.read'); + + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + $role->givePermissionTo(['employees.read', 'shifts.read']); + + $response = $this->withToken($this->token) + ->getJson("/api/v1/roles/{$role->id}"); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => ['id', 'name', 'permissions', 'users_count', 'created_at', 'updated_at'], + ]) + ->assertJsonFragment(['name' => 'Manager']) + ->assertJsonPath('data.permissions', fn ($permissions) => count($permissions) === 2); + }); +}); + +describe('PATCH /v1/roles/{id} - Update Role', function () { + test('returns 401 when not authenticated', function (): void { + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->patchJson("/api/v1/roles/{$role->id}", [ + 'name' => 'Senior Manager', + ]); + + $response->assertUnauthorized(); + }); + + test('returns 403 when user lacks roles.update permission', function (): void { + $this->user->givePermissionTo('roles.read'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->patchJson("/api/v1/roles/{$role->id}", [ + 'name' => 'Senior Manager', + ]); + + $response->assertForbidden(); + }); + + test('returns 404 when role does not exist', function (): void { + $this->user->givePermissionTo('roles.update'); + + $response = $this->withToken($this->token) + ->patchJson('/api/v1/roles/999999', [ + 'name' => 'New Name', + ]); + + $response->assertNotFound(); + }); + + test('returns 422 when name already exists for another role', function (): void { + $this->user->givePermissionTo('roles.update'); + Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + $guard = Role::create(['name' => 'Guard', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->patchJson("/api/v1/roles/{$guard->id}", [ + 'name' => 'Manager', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + }); + + test('updates role name successfully', function (): void { + $this->user->givePermissionTo('roles.update'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->patchJson("/api/v1/roles/{$role->id}", [ + 'name' => 'Senior Manager', + ]); + + $response->assertOk() + ->assertJsonFragment(['name' => 'Senior Manager']); + + expect($role->fresh()->name)->toBe('Senior Manager'); + }); + + test('updates role permissions successfully', function (): void { + $this->user->givePermissionTo('roles.update'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + $role->givePermissionTo('employees.read'); + + $response = $this->withToken($this->token) + ->patchJson("/api/v1/roles/{$role->id}", [ + 'permissions' => ['shifts.read', 'employees.create'], + ]); + + $response->assertOk(); + + $permissions = $role->fresh()->permissions->pluck('name')->toArray(); + expect($permissions) + ->toContain('shifts.read') + ->toContain('employees.create') + ->toHaveCount(2); + }); +}); + +describe('DELETE /v1/roles/{id} - Delete Role', function () { + test('returns 401 when not authenticated', function (): void { + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->deleteJson("/api/v1/roles/{$role->id}"); + + $response->assertUnauthorized(); + }); + + test('returns 403 when user lacks roles.delete permission', function (): void { + $this->user->givePermissionTo('roles.read'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->deleteJson("/api/v1/roles/{$role->id}"); + + $response->assertForbidden(); + }); + + test('returns 404 when role does not exist', function (): void { + $this->user->givePermissionTo('roles.delete'); + + $response = $this->withToken($this->token) + ->deleteJson('/api/v1/roles/999999'); + + $response->assertNotFound(); + }); + + test('returns 422 when role is assigned to users', function (): void { + $this->user->givePermissionTo('roles.delete'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + // Assign role to a user + $otherUser = User::factory()->create(); + $otherUser->assignRole($role); + + $response = $this->withToken($this->token) + ->deleteJson("/api/v1/roles/{$role->id}"); + + $response->assertStatus(422) + ->assertJsonFragment(['message' => 'Cannot delete role while assigned to users']) + ->assertJsonFragment(['assigned_to' => 1]); + }); + + test('deletes role successfully when not assigned', function (): void { + $this->user->givePermissionTo('roles.delete'); + $role = Role::create(['name' => 'Manager', 'guard_name' => 'sanctum']); + + $response = $this->withToken($this->token) + ->deleteJson("/api/v1/roles/{$role->id}"); + + $response->assertNoContent(); + + expect(Role::where('id', $role->id)->exists())->toBeFalse(); + }); +});