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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **User Direct Permission Assignment API** (#138)
- New endpoint: `GET /v1/users/{user}/permissions` - List all user permissions (direct + inherited from roles)
- New endpoint: `POST /v1/users/{user}/permissions` - Assign direct permission(s) to user with temporal tracking (audit trail)
- New endpoint: `DELETE /v1/users/{user}/permissions/{permission}` - Revoke direct permission from user
- New endpoint: `GET /v1/users/{user}/permissions/direct` - List only direct permissions (excludes permissions inherited from roles)
- Uses existing pivot columns (`granted_at`, `granted_by`, `revoked_at`, `revoked_by`) on `model_has_permissions` table for temporal direct permission assignment (no new migration required)
- New controller: `UserPermissionController` - Handles direct permission assignment operations
- New policy: `UserPermissionPolicy` - Authorization rules (User can view own, Admin can assign/revoke)
- New form request: `AssignUserPermissionRequest` - Validates permission existence, not already assigned, and assignment metadata
- New method: `User::hasDirectPermission()` - Check if permission is directly assigned (not via roles)
- Part of RBAC Phase 4 Epic (#108), enables fine-grained permission control bypassing roles

- **Permission Management CRUD API** (#137)
- New endpoint: `GET /v1/permissions` - List all permissions grouped by resource
- New endpoint: `POST /v1/permissions` - Create new permission with strict naming validation (resource.action)
Expand Down
200 changes: 200 additions & 0 deletions app/Http/Controllers/Api/V1/UserPermissionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?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\AssignUserPermissionRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;

/**
* Manages direct permission assignments to users.
*
* Direct permissions bypass roles and are assigned individually.
* Supports temporal constraints (valid_from, valid_until) for time-limited access.
*/
class UserPermissionController extends Controller
{
/**
* List all user permissions (via_roles + direct + all).
*
* Returns permissions grouped by source:
* - via_roles: Permissions inherited from assigned roles
* - direct: Permissions assigned directly to user
* - all: Combined deduplicated list
*
* Authorization: User can view own, Admin can view all
*/
public function index(User $user): JsonResponse
{
Gate::authorize('viewPermissions', $user);

// Get all permissions (via roles + direct)
$allPermissions = $user->getAllPermissions()->pluck('name');

// Get permissions via roles
$rolesWithPermissions = $user->roles->flatMap(function ($role) {
/** @var \Spatie\Permission\Models\Role $role */
return $role->permissions->map(function ($permission) use ($role) {
/** @var \Spatie\Permission\Models\Permission $permission */
return [
'name' => $permission->name,
'role' => $role->name,
];
});
});

// Get direct permissions with pivot data
$directPermissions = DB::table('model_has_permissions')
->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id')
->where('model_has_permissions.model_type', $user->getMorphClass())
->where('model_has_permissions.model_id', $user->getKey())
->select([
'permissions.name',
'model_has_permissions.valid_from',
'model_has_permissions.valid_until',
'model_has_permissions.created_at as assigned_at',
'model_has_permissions.assigned_by',
'model_has_permissions.reason',
])
->get()
->map(function ($row) {
return (array) $row;
});

return response()->json([
'data' => [
'via_roles' => $rolesWithPermissions,
'direct' => $directPermissions,
'all' => $allPermissions->unique()->values(),
],
]);
}

/**
* Assign direct permission(s) to user.
*
* Supports bulk assignment and optional temporal constraints.
* Authorization: Admin only
*/
public function store(AssignUserPermissionRequest $request, User $user): JsonResponse
{
Gate::authorize('assignPermission', $user);

/** @var array<int, string> $permissions */
$permissions = $request->validated('permissions');
$assignedPermissions = [];

foreach ($permissions as $permissionName) {
/** @var \Spatie\Permission\Models\Permission $permission */
$permission = \Spatie\Permission\Models\Permission::findByName((string) $permissionName, 'sanctum');

// Build pivot data array with temporal constraints
$pivotData = [
'valid_from' => $request->validated('valid_from'),
'valid_until' => $request->validated('valid_until'),
'assigned_by' => auth()->id(),
'reason' => $request->validated('reason'),
];

// Insert directly into pivot table with temporal data
// Spatie's givePermissionTo doesn't support pivot attributes
$registrar = app(\Spatie\Permission\PermissionRegistrar::class);
$teamId = $registrar->getPermissionsTeamId();

DB::table('model_has_permissions')->updateOrInsert(
[
'permission_id' => $permission->id,
'model_type' => $user->getMorphClass(),
'model_id' => $user->getKey(),
'tenant_id' => $teamId,
],
array_merge($pivotData, [
'created_at' => now(),
'updated_at' => now(),
])
);

$assignedPermissions[] = [
'name' => $permission->name,
'valid_from' => $pivotData['valid_from'] ?? null,
'valid_until' => $pivotData['valid_until'] ?? null,
];
}

// Clear permission cache
app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();

return response()->json([
'message' => 'Permissions assigned successfully',
'data' => $assignedPermissions,
], 201);
}

/**
* Revoke direct permission from user.
*
* Only removes direct assignment. Role-based permissions remain.
* Authorization: Admin only
*/
public function destroy(User $user, string $permission): JsonResponse
{
Gate::authorize('revokePermission', $user);

// Check if user has this permission directly
if (! $user->hasDirectPermission($permission)) {
return response()->json([
'message' => 'User does not have this permission directly assigned',
], 404);
}

$user->revokePermissionTo($permission);

return response()->json([
'message' => 'Permission revoked successfully',
]);
}

/**
* List only direct permissions (excludes via_roles).
*
* Shows temporal constraints if present.
* Authorization: User can view own, Admin can view all
*/
public function direct(User $user): JsonResponse
{
Gate::authorize('viewPermissions', $user);

// Get direct permissions with pivot data
$directPermissions = DB::table('model_has_permissions')
->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id')
->where('model_has_permissions.model_type', $user->getMorphClass())
->where('model_has_permissions.model_id', $user->getKey())
->select([
'permissions.name',
'model_has_permissions.valid_from',
'model_has_permissions.valid_until',
'model_has_permissions.created_at as assigned_at',
'model_has_permissions.assigned_by',
'model_has_permissions.reason',
])
->get()
->map(function ($row) {
return (array) $row;
});

return response()->json([
'data' => [
'direct' => $directPermissions,
],
]);
}
}
82 changes: 82 additions & 0 deletions app/Http/Requests/AssignUserPermissionRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

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

namespace App\Http\Requests;

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

/**
* Validation for direct permission assignment to users.
*
* Validates:
* - permissions: Array of permission names (must exist)
* - valid_from: Optional timestamp (must be before valid_until)
* - valid_until: Optional timestamp
* - reason: Optional justification text
*/
class AssignUserPermissionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Authorization handled by Gate in controller
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'permissions' => ['required', 'array', 'min:1'],
'permissions.*' => [
'required',
'string',
function (string $attribute, mixed $value, \Closure $fail) {
if (! is_string($value)) {
$fail('The permission must be a string.');

return;
}

if (! Permission::where('name', $value)->where('guard_name', 'sanctum')->exists()) {
$fail("The permission '{$value}' does not exist.");
}
},
],
'valid_from' => ['nullable', 'date', 'before_or_equal:valid_until'],
'valid_until' => ['nullable', 'date', 'after:valid_from'],
'reason' => ['nullable', 'string', 'max:1000'],
];
}

/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'permissions.required' => 'At least one permission must be provided.',
'permissions.array' => 'Permissions must be an array.',
'permissions.min' => 'At least one permission must be provided.',
'permissions.*.required' => 'Permission name cannot be empty.',
'permissions.*.string' => 'Permission name must be a string.',
'valid_from.before_or_equal' => 'Valid from date must be before or equal to valid until date.',
'valid_until.after' => 'Valid until date must be after valid from date.',
'reason.max' => 'Reason cannot exceed 1000 characters.',
];
}
}
27 changes: 27 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,31 @@ public function roles(): MorphToMany
TemporalRoleUser::applyActiveFilter($query, 'model_has_roles.');
});
}

/**
* Check if user has a permission assigned directly (not via roles).
*
* This method queries the model_has_permissions pivot table directly
* to bypass Spatie's role-based permission resolution.
*
* @param string|\Spatie\Permission\Contracts\Permission $permission
*/
public function hasDirectPermission($permission): bool
{
if (is_string($permission)) {
// Use Spatie's Permission model directly to avoid PHPStan complexity
$permission = \Spatie\Permission\Models\Permission::findByName($permission, $this->getDefaultGuardName());
}

if (! $permission instanceof \Spatie\Permission\Contracts\Permission) {
return false;
}

// Query pivot table directly to check for direct assignment
return \Illuminate\Support\Facades\DB::table('model_has_permissions')
->where('model_type', $this->getMorphClass())
->where('model_id', $this->getKey())
->where('permission_id', $permission->id)
->exists();
}
}
59 changes: 59 additions & 0 deletions app/Policies/UserPermissionPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

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

namespace App\Policies;

use App\Models\User;

/**
* Authorization policy for direct permission assignment to users.
*
* Rules:
* - viewPermissions: User can view own, Admin can view all
* - assignPermission: Admin only
* - revokePermission: Admin only
*/
class UserPermissionPolicy
{
/**
* Determine whether the user can view permissions of the target user.
*
* Users can view their own permissions.
* Admins can view any user's permissions.
*/
public function viewPermissions(User $currentUser, User $targetUser): bool
{
// User can view own permissions
if ($currentUser->id === $targetUser->id) {
return true;
}

// Admin can view any user's permissions
return $currentUser->hasRole('Admin');
}

/**
* Determine whether the user can assign permissions to the target user.
*
* Only Admins can assign direct permissions.
*/
public function assignPermission(User $currentUser, User $targetUser): bool
{
return $currentUser->hasRole('Admin');
}

/**
* Determine whether the user can revoke permissions from the target user.
*
* Only Admins can revoke direct permissions.
*/
public function revokePermission(User $currentUser, User $targetUser): bool
{
return $currentUser->hasRole('Admin');
}
}
Loading