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
83 changes: 83 additions & 0 deletions app/Console/Commands/ClearRolePermissionCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Constants\CacheKeys;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

final class ClearRolePermissionCache extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cache:clear-roles-permissions {--user-id= : Clear cache for specific user} {--all : Clear all user caches by incrementing version}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear role and permission caches';

/**
* Execute the console command.
*/
public function handle(): int
{
$userId = $this->option('user-id');
$clearAll = $this->option('all');

if ($userId) {
$user = User::find($userId);
if (! $user) {
$this->error("User with ID {$userId} not found.");

return 1;
}

$user->clearCache();
$this->info("Cache cleared for user: {$user->name} (ID: {$userId})");
} elseif ($clearAll) {
$this->clearAllUserCaches();
$this->info('All user caches cleared by incrementing cache version.');
} else {
$this->clearGlobalCaches();
$this->info('Global role and permission caches cleared successfully.');
}

return 0;
}

/**
* Clear all user caches by incrementing version
*/
private function clearAllUserCaches(): void
{
/** @var int $currentVersion */
$currentVersion = Cache::get('user_cache_version', 1);
$newVersion = $currentVersion + 1;

Cache::put('user_cache_version', $newVersion, CacheKeys::CACHE_TTL);

$this->info("Cache version incremented from {$currentVersion} to {$newVersion}");
$this->info('All user caches are now invalidated.');
}

/**
* Clear global role and permission caches
*/
private function clearGlobalCaches(): void
{
// Clear global caches
Cache::forget(CacheKeys::ALL_ROLES_CACHE_KEY);
Cache::forget(CacheKeys::ALL_PERMISSIONS_CACHE_KEY);

$this->info('Global caches cleared successfully.');
}
}
36 changes: 36 additions & 0 deletions app/Constants/CacheKeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Constants;

final class CacheKeys
{
public const CACHE_TTL = 3600; // 1 hour

// User-specific cache keys
public const USER_ROLES_CACHE_KEY = 'user_roles_';

public const USER_PERMISSIONS_CACHE_KEY = 'user_permissions_';

// Global cache keys
public const ALL_ROLES_CACHE_KEY = 'all_roles_with_permissions';

public const ALL_PERMISSIONS_CACHE_KEY = 'all_permissions';

/**
* Get user roles cache key
*/
public static function userRoles(int $userId): string
{
return self::USER_ROLES_CACHE_KEY.$userId;
}

/**
* Get user permissions cache key
*/
public static function userPermissions(int $userId): string
{
return self::USER_PERMISSIONS_CACHE_KEY.$userId;
}
}
6 changes: 6 additions & 0 deletions app/Http/Resources/V1/Auth/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public function toArray(Request $request): array
'website' => $this->resource->website,
'roles' => $this->whenLoaded('roles', function () {
return $this->resource->roles->pluck('slug');
}, function () {
// Fallback to cached roles if not loaded
return $this->resource->getCachedRoles();
}),
'permissions' => $this->whenLoaded('roles', function () {
$permissionSlugs = [];
Expand All @@ -47,6 +50,9 @@ public function toArray(Request $request): array
}

return array_values(array_unique($permissionSlugs));
}, function () {
// Fallback to cached permissions if not loaded
return $this->resource->getCachedPermissions();
}),
$this->mergeWhen(
array_key_exists('access_token', $this->resource->getAttributes()),
Expand Down
39 changes: 39 additions & 0 deletions app/Models/Role.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Models;

use App\Constants\CacheKeys;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
Expand All @@ -27,6 +28,44 @@ final class Role extends Model

protected $guarded = [];

/**
* Boot the model and register event listeners
*/
protected static function boot(): void
{
parent::boot();

// Clear user caches when role permissions are updated
self::updated(function (Role $role) {
$role->clearUserCaches();
});

// Clear user caches when role is deleted
self::deleted(function (Role $role) {
$role->clearUserCaches();
});
}

/**
* Clear caches for all users who have this role
*/
private function clearUserCaches(): void
{
// Instead of clearing individual user caches, increment the cache version
// This will invalidate all user caches at once
$this->incrementCacheVersion();
}

/**
* Increment cache version to invalidate all user caches
*/
private function incrementCacheVersion(): void
{
/** @var int $currentVersion */
$currentVersion = \Illuminate\Support\Facades\Cache::get('user_cache_version', 1);
\Illuminate\Support\Facades\Cache::put('user_cache_version', $currentVersion + 1, CacheKeys::CACHE_TTL);
}

/**
* Get the attributes that should be cast.
*
Expand Down
136 changes: 134 additions & 2 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Models;

use App\Traits\HasCachedRolesAndPermissions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
Expand Down Expand Up @@ -44,7 +45,7 @@
*/
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens, HasCachedRolesAndPermissions, HasFactory, Notifiable;

/**
* The attributes that are mass assignable.
Expand Down Expand Up @@ -76,6 +77,24 @@ class User extends Authenticatable
'remember_token',
];

/**
* Boot the model and register event listeners
*/
protected static function boot(): void
{
parent::boot();

// Clear cache when user is updated
static::updated(function (User $user) {
$user->clearCache();
});

// Clear cache when user is deleted
static::deleted(function (User $user) {
$user->clearCache();
});
}

/**
* Get the attributes that should be cast.
*
Expand Down Expand Up @@ -112,13 +131,53 @@ public function hasRole(string $role): bool
return $this->roles->pluck('name')->contains($role);
}

/**
* Check if the user has any of the given roles.
*
* @param array<int, string> $roles
*/
public function hasAnyRole(array $roles): bool
{
$userRoles = $this->roles->pluck('name')->toArray();

return ! empty(array_intersect($roles, $userRoles));
}

/**
* Check if the user has all of the given roles.
*
* @param array<int, string> $roles
*/
public function hasAllRoles(array $roles): bool
{
$userRoles = $this->roles->pluck('name')->toArray();

return empty(array_diff($roles, $userRoles));
}

/**
* Check if the user has a given permission via their roles.
*/
public function hasPermission(string $permission): bool
{
try {
// Load roles with permissions to avoid N+1 queries
// Try to get from cache first (most efficient)
if ($this->hasCachedPermission($permission)) {
return true;
}

// If cache miss, check if roles are already loaded
if ($this->relationLoaded('roles')) {
foreach ($this->roles as $role) {
if ($role->relationLoaded('permissions')) {
if ($role->permissions->contains('name', $permission)) {
return true;
}
}
}
}

// Fallback to database query with eager loading
$this->load('roles.permissions');

foreach ($this->roles as $role) {
Expand All @@ -142,6 +201,79 @@ public function hasPermission(string $permission): bool
}
}

/**
* Check if the user has any of the given permissions.
*
* @param array<int, string> $permissions
*/
public function hasAnyPermission(array $permissions): bool
{
try {
// Try to get from cache first
if ($this->hasAnyCachedPermission($permissions)) {
return true;
}

// Fallback to database query
$this->load('roles.permissions');

foreach ($this->roles as $role) {
foreach ($role->permissions as $permission) {
if (in_array($permission->name, $permissions, true)) {
return true;
}
}
}

return false;
} catch (\Throwable $e) {
\Log::error('hasAnyPermission error', [
'user_id' => $this->id,
'permissions' => $permissions,
'error' => $e->getMessage(),
]);

return false;
}
}

/**
* Check if the user has all of the given permissions.
*
* @param array<int, string> $permissions
*/
public function hasAllPermissions(array $permissions): bool
{
try {
// Try to get from cache first
if ($this->hasAllCachedPermissions($permissions)) {
return true;
}

// Fallback to database query
$this->load('roles.permissions');

$userPermissions = [];
foreach ($this->roles as $role) {
foreach ($role->permissions as $permission) {
$userPermissions[] = $permission->name;
}
}

$userPermissions = array_unique($userPermissions);

return empty(array_diff($permissions, $userPermissions));
} catch (\Throwable $e) {
\Log::error('hasAllPermissions error', [
'user_id' => $this->id,
'permissions' => $permissions,
'error' => $e->getMessage(),
]);

return false;
}
}

/**
* Get the articles created by the user.
*
Expand Down
Loading