Autorización multinivel en Laravel: políticas, roles y ABAC con caché en Redis
Introducción: limitaciones del Gate y las Policies estándar de Laravel
Laravel ofrece potentes herramientas de autorización integradas: Gate, clases Policy y el middleware auth. Para aplicaciones pequeñas esto es suficiente: basta con definir algunos permisos en AuthServiceProvider y seguir adelante. Sin embargo, en sistemas empresariales con decenas de roles, cientos de permisos y reglas de negocio complejas, el enfoque estándar comienza a quedarse corto.
Imagina un sistema de gestión documental donde el acceso a un archivo depende no solo del rol del usuario, sino también del departamento al que pertenece el documento, del estado del propio documento, de una ventana temporal (horario laboral), de la geolocalización de la solicitud y del nivel de confidencialidad. Un Gate estándar con verificaciones hardcodeadas se convierte en código espagueti imposible de probar y mantener.
Otro problema es el rendimiento. En cada solicitud HTTP, Laravel ejecuta consultas a la base de datos para verificar los permisos. En sistemas de alta carga esto se convierte en un cuello de botella: incluso 3–5 consultas SQL adicionales por cada llamada a la API con 1000 RPS generan una carga enorme sobre PostgreSQL.
En este artículo construiremos un sistema de autorización multinivel para aplicaciones Laravel que combina el modelo basado en roles (RBAC) con el control de acceso basado en atributos (ABAC), almacena en caché los permisos calculados en Redis y se mantiene testeable en cada nivel. El público objetivo son desarrolladores Laravel de nivel Middle/Senior que necesitan una arquitectura bien pensada, no otra librería «lista para usar».
Comparación de modelos: RBAC vs ABAC vs enfoque híbrido
RBAC (Role-Based Access Control) — el enfoque clásico: al usuario se le asigna un rol, y al rol le corresponde un conjunto de permisos. Simple de implementar y fácil de entender para el negocio. Limitación: los roles no tienen en cuenta el contexto. No es posible expresar la regla «el gerente puede editar pedidos solo de su región».
ABAC (Attribute-Based Access Control) — la decisión se toma en función de los atributos del sujeto (usuario), el objeto (recurso), la acción y el entorno (contexto de la solicitud). Extremadamente flexible, pero complejo de implementar y depurar. Sin caché, es costoso en términos de rendimiento.
Enfoque híbrido — lo mejor de ambos mundos: RBAC se usa como filtro rápido de primer nivel (¿tiene el rol el permiso en absoluto?), y ABAC como segundo nivel (¿es aplicable el permiso en un contexto específico?). Es precisamente este enfoque el que vamos a implementar.
- Usa RBAC puro: SaaS pequeños, CMS, paneles de administración con jerarquía de roles bien definida.
- Usa el híbrido RBAC+ABAC: sistemas financieros, ERP, sistemas de gestión documental, plataformas multitenancy.
- Usa ABAC puro: sistemas gubernamentales con requisitos normativos (GDPR, HIPAA) donde las reglas cambian dinámicamente.
Diseño del esquema de BD en PostgreSQL
Un esquema bien diseñado es el fundamento de todo el sistema. Usamos PostgreSQL con soporte JSONB para almacenar atributos.
-- Roles del sistema
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128),
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Permisos
CREATE TABLE permissions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE, -- por ejemplo: documents.edit
resource VARCHAR(64) NOT NULL, -- documents
action VARCHAR(64) NOT NULL, -- edit
description TEXT
);
-- Relación entre roles y permisos
CREATE TABLE role_permissions (
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Asignación de roles a usuarios (con soporte de scope)
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
scope_type VARCHAR(64), -- por ejemplo: department, organization
scope_id BIGINT, -- ID del departamento específico
expires_at TIMESTAMPTZ,
PRIMARY KEY (user_id, role_id, COALESCE(scope_type, ''), COALESCE(scope_id, 0))
);
-- Atributos de recursos para ABAC
CREATE TABLE resource_attributes (
id BIGSERIAL PRIMARY KEY,
resource_type VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(resource_type, resource_id)
);
CREATE INDEX idx_resource_attrs ON resource_attributes USING GIN(attributes);
CREATE INDEX idx_user_roles_user ON user_roles(user_id);
CREATE INDEX idx_user_roles_scope ON user_roles(scope_type, scope_id);
La columna scope_type/scope_id en user_roles permite asignar un rol en el contexto de un objeto específico — por ejemplo, «gerente solo en el departamento №5». JSONB en resource_attributes ofrece flexibilidad sin necesidad de migraciones al agregar nuevos atributos.
Implementación de RBAC en Laravel: Gate, Policy y roles
Empezamos con el servicio de carga de permisos del usuario:
<?php
namespace App\Authorization;
use App\Models\User;
use Illuminate\Support\Collection;
class PermissionLoader
{
public function loadForUser(User $user): Collection
{
return $user->roles()
->with('permissions')
->get()
->flatMap(fn($role) => $role->permissions)
->pluck('name')
->unique();
}
}
Registramos los permisos en AuthServiceProvider mediante un Gate personalizado:
<?php
namespace App\Providers;
use App\Authorization\PermissionLoader;
use App\Authorization\PermissionCache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
public function boot(PermissionLoader $loader, PermissionCache $cache): void
{
// Definición dinámica de todos los permisos mediante Gate::before
Gate::before(function ($user, $ability) use ($loader, $cache) {
// El superadmin omite todas las verificaciones
if ($user->hasRole('super-admin')) {
return true;
}
// Cargamos los permisos desde caché o BD
$permissions = $cache->get($user->id)
?? tap($loader->loadForUser($user), fn($p) => $cache->put($user->id, $p));
// Primer nivel: verificación RBAC
if (!$permissions->contains($ability)) {
return false;
}
// null = continuar hacia Policy para la verificación ABAC
return null;
});
}
}
Ahora creamos la Policy para documentos con lógica ABAC:
<?php
namespace App\Policies;
use App\Models\{User, Document};
use App\Authorization\AbacEvaluator;
class DocumentPolicy
{
public function __construct(private AbacEvaluator $abac) {}
public function view(User $user, Document $document): bool
{
// ABAC: verificamos los atributos
return $this->abac->evaluate($user, $document, 'view');
}
public function edit(User $user, Document $document): bool
{
return $this->abac->evaluate($user, $document, 'edit');
}
public function delete(User $user, Document $document): bool
{
// Verificación adicional: no se pueden eliminar documentos finalizados
if ($document->status === 'finalized') {
return false;
}
return $this->abac->evaluate($user, $document, 'delete');
}
}
Extensión a ABAC: verificación de atributos en AbacEvaluator
La clase clave es AbacEvaluator, que evalúa las reglas en función de los atributos del sujeto, el objeto y el contexto:
<?php
namespace App\Authorization;
use App\Models\{User, Document};
use Illuminate\Http\Request;
class AbacEvaluator
{
public function __construct(private Request $request) {}
public function evaluate(User $user, object $resource, string $action): bool
{
$rules = $this->getRulesFor(get_class($resource), $action);
foreach ($rules as $rule) {
if (!$this->applyRule($rule, $user, $resource)) {
return false;
}
}
return true;
}
private function applyRule(array $rule, User $user, object $resource): bool
{
return match ($rule['type']) {
// El usuario debe pertenecer al mismo departamento que el recurso
'same_department' =>
$user->department_id === $resource->department_id,
// Verificación de ventana temporal (horario laboral)
'business_hours' =>
now()->isWeekday() && now()->hour >= 9 && now()->hour < 18,
// Nivel de confidencialidad
'clearance_level' =>
$user->clearance_level >= ($resource->attributes['sensitivity_level'] ?? 0),
// IP desde subred de confianza
'trusted_network' =>
$this->isFromTrustedNetwork($this->request->ip()),
default => true,
};
}
private function getRulesFor(string $resourceClass, string $action): array
{
// En una aplicación real — carga desde BD o configuración
return config("abac.rules.{$resourceClass}.{$action}", []);
}
private function isFromTrustedNetwork(string $ip): bool
{
$trustedRanges = config('abac.trusted_networks', []);
foreach ($trustedRanges as $range) {
if ($this->ipInRange($ip, $range)) return true;
}
return false;
}
private function ipInRange(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
return (ip2long($ip) >> (32 - (int)$bits)) === (ip2long($subnet) >> (32 - (int)$bits));
}
}
Caché de permisos en Redis: estrategia e invalidación
Redis es la herramienta ideal para almacenar en caché los permisos calculados. Usamos etiquetas de caché de Laravel para la invalidación agrupada:
<?php
namespace App\Authorization;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
class PermissionCache
{
private const TTL = 3600; // 1 hora
private const TAG_PREFIX = 'user_permissions';
public function get(int $userId): ?Collection
{
return Cache::tags([$this->tag($userId)])
->get($this->key($userId));
}
public function put(int $userId, Collection $permissions): void
{
Cache::tags([$this->tag($userId), 'permissions'])
->put($this->key($userId), $permissions, self::TTL);
}
// Invalidación al cambiar el rol o los permisos de un usuario específico
public function invalidateUser(int $userId): void
{
Cache::tags([$this->tag($userId)])->flush();
}
// Invalidación de todos los permisos en caché (al modificar el esquema de roles)
public function invalidateAll(): void
{
Cache::tags(['permissions'])->flush();
}
private function key(int $userId): string
{
return "permissions:user:{$userId}";
}
private function tag(int $userId): string
{
return self::TAG_PREFIX . ":{$userId}";
}
}
Conectamos la invalidación mediante eventos Eloquent. En el modelo UserRole:
<?php
namespace App\Models;
use App\Authorization\PermissionCache;
use Illuminate\Database\Eloquent\Model;
class UserRole extends Model
{
protected static function booted(): void
{
$invalidate = function (self $model) {
app(PermissionCache::class)->invalidateUser($model->user_id);
};
static::created($invalidate);
static::deleted($invalidate);
static::updated($invalidate);
}
}
La configuración de Redis en config/database.php debe usar PhpRedis con soporte de etiquetas mediante el driver redis en config/cache.php con 'driver' => 'redis'. Las etiquetas requieren obligatoriamente Redis — el caché de archivos no las soporta.
Middleware para autorización de la API REST
Creamos un middleware expresivo que aplica las políticas a nivel de rutas:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class AuthorizeResource
{
public function handle(Request $request, Closure $next, string $ability, string $modelParam = null): mixed
{
$user = $request->user();
if ($modelParam) {
$model = $request->route($modelParam);
Gate::authorize($ability, $model);
} else {
Gate::authorize($ability);
}
return $next($request);
}
}
Registro de rutas con middleware:
// routes/api.php
Route::middleware(['auth:sanctum'])->group(function () {
Route::get('/documents/{document}', [DocumentController::class, 'show'])
->middleware('authorize:documents.view,document');
Route::put('/documents/{document}', [DocumentController::class, 'update'])
->middleware('authorize:documents.edit,document');
Route::delete('/documents/{document}', [DocumentController::class, 'destroy'])
->middleware('authorize:documents.delete,document');
});
Testing del sistema de autorización
Una cobertura de pruebas completa es un requisito indispensable para un sistema de autorización en producción.
<?php
namespace Tests\Unit\Policies;
use App\Models\{User, Document};
use App\Policies\DocumentPolicy;
use App\Authorization\AbacEvaluator;
use Tests\TestCase;
use Mockery;
class DocumentPolicyTest extends TestCase
{
public function test_user_cannot_edit_finalized_document(): void
{
$user = User::factory()->create(['clearance_level' => 3]);
$document = Document::factory()->make([
'status' => 'finalized',
'department_id' => $user->department_id,
]);
$policy = new DocumentPolicy(app(AbacEvaluator::class));
$this->assertFalse($policy->delete($user, $document));
}
public function test_user_can_view_document_in_same_department(): void
{
$user = User::factory()->create(['department_id' => 5, 'clearance_level' => 1]);
$document = Document::factory()->make([
'department_id' => 5,
'attributes' => ['sensitivity_level' => 1],
]);
$this->assertTrue(Gate::forUser($user)->allows('documents.view', $document));
}
public function test_permission_cache_is_invalidated_on_role_change(): void
{
$user = User::factory()->create();
$cache = app(PermissionCache::class);
// Llenamos el caché
$cache->put($user->id, collect(['documents.view']));
$this->assertNotNull($cache->get($user->id));
// Cambiamos el rol — debe activarse la invalidación
$user->roles()->sync([]);
$this->assertNull($cache->get($user->id));
}
}
Rendimiento: benchmarks con y sin caché
Realizamos pruebas de carga en el endpoint REST GET /documents/{id} con PostgreSQL 16 y Redis 7.2, Laravel 11, PHP 8.3 (FrankenPHP).
- Sin caché: tiempo promedio de autorización — 18 ms, percentil 95 — 34 ms, 5 consultas SQL por verificación de permisos.
- Con caché Redis (TTL 1 hora): tiempo promedio de autorización — 1,2 ms, percentil 95 — 2,8 ms, 0 consultas SQL en cache hit.
- Latencia total de la API a 500 RPS: sin caché — 87 ms, con caché — 24 ms.
El ahorro en latencia fue de aproximadamente un 72%. La invalidación del caché ocurre de forma instantánea a través de eventos Eloquent, lo que garantiza la actualidad de los permisos.
Un aspecto importante: al usar etiquetas de Redis, asegúrate de que Redis esté configurado con suficiente memoria y la política de expulsión allkeys-lru. Las etiquetas almacenan metadatos, lo que incrementa el consumo de memoria aproximadamente un 15–20% en comparación con el caché convencional.
Auditoría y registro de decisiones de autorización
Para cumplir con requisitos de compliance (SOC 2, GDPR) es necesario registrar las decisiones de autorización. Añadimos un listener:
<?php
namespace App\Listeners;
use Illuminate\Auth\Access\Events\GateEvaluated;
use Illuminate\Support\Facades\Log;
class LogAuthorizationDecision
{
public function handle(GateEvaluated $event): void
{
if ($event->result === false) {
Log::channel('audit')->warning('Authorization denied', [
'user_id' => $event->user?->id,
'ability' => $event->ability,
'arguments' => collect($event->arguments)->map(fn($a) => [
'type' => get_class($a),
'id' => $a->getKey() ?? null,
])->toArray(),
'ip' => request()->ip(),
'at' => now()->toIso8601String(),
]);
}
}
}
Registramos el evento en EventServiceProvider:
use Illuminate\Auth\Access\Events\GateEvaluated;
use App\Listeners\LogAuthorizationDecision;
protected $listen = [
GateEvaluated::class => [
LogAuthorizationDecision::class,
],
];
Los logs se escriben en un canal separado audit (configúralo en config/logging.php) y, si es necesario, se envían a un sistema centralizado — ELK Stack o ClickHouse para análisis de intentos de acceso no autorizado.
Conclusión y recomendaciones
Construir un sistema de autorización multinivel en Laravel es una decisión arquitectónica que conviene tomar al inicio del proyecto, no durante la fase de escalado. Estas son las conclusiones clave:
- RBAC+ABAC híbrido ofrece el equilibrio óptimo entre rendimiento y flexibilidad. RBAC es el filtro rápido de primer nivel; ABAC, el segundo nivel detallado.
- PostgreSQL con JSONB permite almacenar atributos arbitrarios de los recursos sin migraciones al cambiar las reglas de negocio.
- Redis con caché etiquetado reduce la latencia de autorización de 18 ms a 1,2 ms en cache hit. La invalidación mediante eventos Eloquent garantiza la consistencia.
- No abuses de Gate::before — devuelve
null(nofalse) cuando deseas pasar el control a una Policy. De lo contrario, perderás el nivel de verificación ABAC. - Prueba cada nivel por separado: pruebas unitarias para AbacEvaluator, Policy y PermissionCache; pruebas de integración para middleware y rutas.
- El registro de auditoría no es opcional, sino un requisito para sistemas enterprise. Configura un canal de log independiente con rotación.
Laravel en 2026 sigue siendo una excelente base para construir sistemas de autorización complejos. Los componentes estándar (Gate, Policy) son suficientemente flexibles para implementar sobre ellos cualquier modelo de control de acceso — lo importante es no violar los principios de separación de responsabilidades y no descuidar el rendimiento desde el primer día.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →