Laravel Policies: Cómo garantizar la seguridad de tu aplicación web

Laravel Policies: Cómo garantizar la seguridad de tu aplicación web

¿Alguna vez has querido saber cómo proteger ciertas acciones de tu aplicación web de usuarios no autorizados? Te presento a las Policies.

En este tutorial tendremos 2 usuarios (administrador y empleado). El administrador tendrá todas las acciones sobre una Tarea, en cambio el empleado solo podrá ver las tareas que se le asignó.

Requisitos Previos

He preparado un pequeño proyecto base que contiene todo lo necesario para empezar el tutorial, lo puedes descargar aquí. Lo que contiene es:

  • El paquete Breeze para tener la autenticación.
  • Una tabla usuarios con la columna “role” (Administrador o Empleado).
  • Una tabla tareas en la cual cada tarea es asignada a un usuario con el rol empleado.
  • Un controlador de Tareas que tiene como funciones "Ver todas las tareas" (index), "Ver detalle de una tarea" (show) y "Registrar tarea" (store).

Luego de clonar el repositorio, ingresamos a la carpeta y nos vamos al branch starter con el siguiente comando.

1git checkout starter

Luego creamos las tablas con información de prueba en la base de datos con el siguiente comando.

1php artisan migrate --seed

¿Qué son las Policies?

Las Policies es una característica que Laravel nos provee para que podamos asignar quién podrá ejecutar acciones sobre un modelo en particular.

Como puedes ver en la imagen, cada modelo tiene ciertas acciones y nosotros podemos autorizar quién podrá ejecutar cada acción.

Creando el Policy

Para crear el policy del modelo Task, ejecutamos el comando:

1php artisan make:policy TaskPolicy --model=Task

Lo que nos creará el archivo app/Policies/TaskPolicy.php

1<?php
2 
3namespace App\Policies;
4 
5use App\Models\Task;
6use App\Models\User;
7use Illuminate\Auth\Access\Response;
8 
9class TaskPolicy
10{
11 /**
12 * Determine whether the user can view any models.
13 */
14 public function viewAny(User $user): bool
15 {
16 //
17 }
18 
19 /**
20 * Determine whether the user can view the model.
21 */
22 public function view(User $user, Task $task): bool
23 {
24 //
25 }
26 
27 /**
28 * Determine whether the user can create models.
29 */
30 public function create(User $user): bool
31 {
32 //
33 }
34 
35 /**
36 * Determine whether the user can update the model.
37 */
38 public function update(User $user, Task $task): bool
39 {
40 //
41 }
42 
43 /**
44 * Determine whether the user can delete the model.
45 */
46 public function delete(User $user, Task $task): bool
47 {
48 //
49 }
50 
51 /**
52 * Determine whether the user can restore the model.
53 */
54 public function restore(User $user, Task $task): bool
55 {
56 //
57 }
58 
59 /**
60 * Determine whether the user can permanently delete the model.
61 */
62 public function forceDelete(User $user, Task $task): bool
63 {
64 //
65 }
66}

Cada función del policy está relacionado a cada acción del controlador tal como se ve en la documentación oficial.

Para hacer esa relación entre las funciones del Policy y el Controlador, tenemos que autorizar en el constructor del controlador que usaremos el Policy del Task.

Además modificaremos la lógica de la función index que nos retorna todas las tareas para que en caso el usuario sea empleado nos traiga solo las que se les ha asignado.

1<?php
2 
3namespace App\Http\Controllers;
4 
5use App\Models\Task;
6use App\Models\User;
7use Illuminate\Http\Request;
8 
9class TaskController extends Controller
10{
11 /** //
12 * Create the controller instance.
13 */
14 public function __construct()
15 {
16 $this->authorizeResource(Task::class, 'task');
17 }
18 
19 public function index()
20 {
21 $tasks = Task::query()
22 ->with('user')
23 ->when(auth()->user()->isEmployee(), function ($query) {
24 return $query->whereUserId(auth()->id());
25 })
26 ->get();
27 
28 return view('task-index', compact('tasks'));
29 }
30 
31 public function create()
32 {
33 $employees = User::select(['id', 'name'])
34 ->whereRole(User::ROLE_EMPLOYEE)
35 ->get();
36 
37 return view('task-create', compact('employees'));
38 }
39 
40 public function store(Request $request)
41 {
42 $validated = $request->validate([
43 'title' => 'required|string|max:100',
44 'description' => 'required|string|max:300',
45 'user_id' => 'required|exists:users,id'
46 ]);
47 
48 Task::create($validated);
49 
50 return redirect(route('tasks.index'));
51 }
52 
53 public function show(Task $task)
54 {
55 $task->load('user');
56 
57 return view('task-show', compact('task'));
58 }
59 
60 public function edit(Task $task)
61 {
62 }
63 
64 public function update(Request $request, Task $task)
65 {
66 }
67 
68 public function destroy(Task $task)
69 {
70 }
71}

Como lo especificamos al empezar el blog, vamos hacer que solo el administrador pueda tener acceso a todas las acciones, para ello aplicamos la función "before" que se ejecutará antes que todos las funciones.

1<?php
2 
3namespace App\Policies;
4 
5use App\Models\Task;
6use App\Models\User;
7use Illuminate\Auth\Access\Response;
8 
9class TaskPolicy
10{
11 /** //
12 * Perform pre-authorization checks.
13 */
14 public function before(User $user, string $ability): bool|null
15 {
16 if ($user->isAdministrator()) {
17 return true;
18 }
19 
20 return null;
21 }
22 
23 /**
24 * Determine whether the user can view any models.
25 */
26 public function viewAny(User $user): bool
27 {
28 //
29 }
30 
31 /**
32 * Determine whether the user can view the model.
33 */
34 public function view(User $user, Task $task): bool
35 {
36 //
37 }
38 
39 /**
40 * Determine whether the user can create models.
41 */
42 public function create(User $user): bool
43 {
44 //
45 }
46 
47 /**
48 * Determine whether the user can update the model.
49 */
50 public function update(User $user, Task $task): bool
51 {
52 //
53 }
54 
55 /**
56 * Determine whether the user can delete the model.
57 */
58 public function delete(User $user, Task $task): bool
59 {
60 //
61 }
62 
63 /**
64 * Determine whether the user can restore the model.
65 */
66 public function restore(User $user, Task $task): bool
67 {
68 //
69 }
70 
71 /**
72 * Determine whether the user can permanently delete the model.
73 */
74 public function forceDelete(User $user, Task $task): bool
75 {
76 //
77 }
78}

Luego editaremos las siguientes funciones:

  • viewAny (Ver todas las tareas): retornamos true ya que aparte del administrador, el empleado también podrá ver todas las tareas pero solo las que se ha asignado.
1public function viewAny(User $user): bool
2{
3 return true;
4}
  • view (Ver el detalle de una tarea): aparte del administrador, también lo verá un usuario con rol empleado, pero siempre y cuando se le haya asignado
1public function view(User $user, Task $task): bool
2{
3 return $user->isEmployee() && $user->id == $task->user_id;
4}
  • create (Registrar tarea) y las demás acciones: retornamos falso ya que ningún rol aparte del administrador podrá ejecutarlas.
1public function create(User $user): bool
2{
3 return false;
4}
5 
6/**
7* Determine whether the user can update the model.
8*/
9public function update(User $user, Task $task): bool
10{
11 return false;
12}
13 
14/**
15* Determine whether the user can delete the model.
16*/
17public function delete(User $user, Task $task): bool
18{
19 return false;
20}
21 
22/**
23* Determine whether the user can restore the model.
24*/
25public function restore(User $user, Task $task): bool
26{
27 return false;
28}
29 
30/**
31* Determine whether the user can permanently delete the model.
32*/
33public function forceDelete(User $user, Task $task): bool
34{
35 return false;
36}

Probando el Policy en la Aplicación

Estos son los usuarios que usaremos en nuestra aplicación que ya están en nuestra base de datos.

Administrador

Usuario: administrador@laraveleando.com

Contraseña: password

Empleados

Usuario: empleado1@laraveleando.com

Contraseña: password

Usuario: empleado2@laraveleando.com

Contraseña: password

  • Un administrador pueda registrar una tarea.

  • Un administrador puede ver todas las tareas.

  • Un administrador puede ver el detalle de cualquier tarea dando clic en el nombre de una tarea.

  • El empleado solo puede ver sus tareas asignadas.

  • El empleado 1 no puede el detalle de otra tarea. En este caso aunque no nos aparece la tarea del empleado 2, podemos navegar mediante la ruta (/tasks/2), pero nos tiene que aparecer un mensaje de "No Autorizado".

  • El empleado al ir a la ruta "Registrar Tarea" le aparece un mensaje de "No Autorizado".

Como podemos ver todo está funcionando como lo esperado, pero podemos hacer algo más. Dado que solo el administrador puede "Registrar una Tarea", vamos a ocultar ese item del menú si es que el usuario es empleado.

Usando Policy en la vista Blade

Laravel nos provee de directivas Blade como @can/@endcan que podemos usar para validar las acciones de las Policy, en este caso del modelo Task.

Ocultamos los items del menú tanto en desktop como en mobile en el archivo resources/views/layouts/navigation.blade.php.

1<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
2 <!-- Primary Navigation Menu -->
3 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
4 <div class="flex justify-between h-16">
5 <div class="flex">
6 <!-- Logo -->
7 <div class="shrink-0 flex items-center">
8 <a href="{{ route('dashboard') }}">
9 <x-application-logo class="block h-9 w-auto fill-current text-gray-800"/>
10 </a>
11 </div>
12 
13 <!-- Navigation Links -->
14 <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
15 <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
16 {{ __('Dashboard') }}
17 </x-nav-link>
18 <x-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.index')">
19 {{ __('Ver Tareas') }}
20 </x-nav-link>
21 @can('create', App\Models\Task::class)
22 <x-nav-link :href="route('tasks.create')" :active="request()->routeIs('tasks.create')">
23 {{ __('Registrar Tarea') }}
24 </x-nav-link>
25 @endcan
26 </div>
27 </div>
28 
29 <!-- Settings Dropdown -->
30 <div class="hidden sm:flex sm:items-center sm:ml-6">
31 <x-dropdown align="right" width="48">
32 <x-slot name="trigger">
33 <button
34 class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
35 <div>{{ Auth::user()->name }}</div>
36 
37 <div class="ml-1">
38 <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
39 viewBox="0 0 20 20">
40 <path fill-rule="evenodd"
41 d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
42 clip-rule="evenodd"/>
43 </svg>
44 </div>
45 </button>
46 </x-slot>
47 
48 <x-slot name="content">
49 <x-dropdown-link :href="route('profile.edit')">
50 {{ __('Profile') }}
51 </x-dropdown-link>
52 
53 <!-- Authentication -->
54 <form method="POST" action="{{ route('logout') }}">
55 @csrf
56 
57 <x-dropdown-link :href="route('logout')"
58 onclick="event.preventDefault();
59 this.closest('form').submit();">
60 {{ __('Log Out') }}
61 </x-dropdown-link>
62 </form>
63 </x-slot>
64 </x-dropdown>
65 </div>
66 
67 <!-- Hamburger -->
68 <div class="-mr-2 flex items-center sm:hidden">
69 <button @click="open = ! open"
70 class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
71 <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
72 <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex"
73 stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
74 d="M4 6h16M4 12h16M4 18h16"/>
75 <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round"
76 stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
77 </svg>
78 </button>
79 </div>
80 </div>
81 </div>
82 
83 <!-- Responsive Navigation Menu -->
84 <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
85 <div class="pt-2 pb-3 space-y-1">
86 <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
87 {{ __('Dashboard') }}
88 </x-responsive-nav-link>
89 <x-responsive-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.index')">
90 {{ __('Ver Tareas') }}
91 </x-responsive-nav-link>
92 @can('create', App\Models\Task::class)
93 <x-responsive-nav-link :href="route('tasks.create')" :active="request()->routeIs('tasks.create')">
94 {{ __('Registrar Tarea') }}
95 </x-responsive-nav-link>
96 @endcan
97 </div>
98 
99 <!-- Responsive Settings Options -->
100 <div class="pt-4 pb-1 border-t border-gray-200">
101 <div class="px-4">
102 <div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
103 <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
104 </div>
105 
106 <div class="mt-3 space-y-1">
107 <x-responsive-nav-link :href="route('profile.edit')">
108 {{ __('Profile') }}
109 </x-responsive-nav-link>
110 
111 <!-- Authentication -->
112 <form method="POST" action="{{ route('logout') }}">
113 @csrf
114 
115 <x-responsive-nav-link :href="route('logout')"
116 onclick="event.preventDefault();
117 this.closest('form').submit();">
118 {{ __('Log Out') }}
119 </x-responsive-nav-link>
120 </form>
121 </div>
122 </div>
123 </div>
124</nav>

Entramos como empleado y vemos que ya no nos aparece el item del menú "Registrar Tarea".

Creando Tests

Vamos a crear algunos tests para darle más seguridad a nuestra aplicación y de esta manera ahorremos tiempo en probar cuando actualicemos o añadamos nuevas funcionalidades. Vamos a usar Pest que es un framework de testing para PHP.

Dado que toda la lógica está en nuestro controlador, una manera es que creamos el test con la misma ruta y nombre, por ello crearemos el test en la ruta tests/Feature/Http/Controllers/TaskControllerTest.php.

Estos son los tests que crearemos:

  1. El administrador puede ver todas las tareas registradas.
1<?php
2 
3use App\Models\Task;
4use App\Models\User;
5use function Pest\Laravel\actingAs;
6 
7it('returns all the tasks when user is administrator', function () {
8 
9 // Preparación
10 $admin = User::factory()
11 ->administrator()
12 ->create();
13 
14 User::factory()
15 ->employee()
16 ->has(Task::factory()->count(3))
17 ->create();
18 
19 User::factory()
20 ->employee()
21 ->has(Task::factory()->count(2))
22 ->create();
23 
24 // Acción y Confirmar
25 actingAs($admin)
26 ->get(route('tasks.index'))
27 // La petición sea satisfactoria
28 ->assertOk()
29 // Nos retorne la vista task-index
30 ->assertViewIs('task-index')
31 // La vista retornada tenga todos los tasks registrados
32 ->assertViewHas('tasks', Task::all());
33});
  1. El empleado solo puede ver sus tareas asignadas.
1it('returns only the tasks assigned to the employee user', function () {
2 // Preparación
3 $employee01 = User::factory()
4 ->employee()
5 ->has(Task::factory()->count(3))
6 ->create();
7 
8 $employee02 = User::factory()
9 ->employee()
10 ->has(Task::factory()->count(3))
11 ->create();
12 
13 // Acción y Confirmar
14 actingAs($employee01)
15 ->get(route('tasks.index'))
16 // La petición sea satisfactoria
17 ->assertOk()
18 // Nos retorne la vista task-index
19 ->assertViewIs('task-index')
20 // La vista retornada solo las 3 tareas registradas
21 ->assertViewHas('tasks', $employee01->tasks);
22});
  1. El administrador puede ingresar a la vista de registrar tarea.
1it('returns create task view when user is administrator', function () {
2 // Preparación
3 $admin = User::factory()
4 ->administrator()
5 ->create();
6 
7 // Acción y Confirmar
8 actingAs($admin)
9 ->get(route('tasks.create'))
10 // La petición sea satisfactoria
11 ->assertOk()
12 // Nos retorne la vista task-create
13 ->assertViewIs('task-create');
14});
  1. El empleado recibe un mensaje de no autorizado cuando ingresa a registrar tarea.
1it('returns unauthorized action when user is employee and try to see the create task view', function () {
2 // Preparación
3 $employee = User::factory()
4 ->employee()
5 ->create();
6 
7 // Acción y Confirmar
8 
9 actingAs($employee)
10 ->get(route('tasks.create'))
11 // La petición está prohibida
12 ->assertForbidden();
13});
  1. El administrador puede registrar una tarea.
1use function Pest\Laravel\assertDatabaseCount;
2use function Pest\Laravel\assertDatabaseHas;
3 
4it('can register a task when user is administrator', function () {
5 // Preparación
6 $admin = User::factory()
7 ->administrator()
8 ->create();
9 
10 $employee = User::factory()
11 ->employee()
12 ->create();
13 
14 $task = [
15 'title' => fake()->title,
16 'description' => fake()->text(200),
17 'user_id' => $employee->id
18 ];
19 
20 // Acción y Confirmar
21 actingAs($admin)
22 ->post(route('tasks.store'), $task)
23 ->assertRedirect(route('tasks.index'));
24 
25 // La tabla "tasks" tiene 1 registro
26 assertDatabaseCount('tasks', 1);
27 // La tabla "tasks" tiene el registro de la variable task
28 assertDatabaseHas('tasks', $task);
29});
  1. El empleado no puede registrar una tarea.
1it('cannot register a task when user is employee', function () {
2 // Preparación
3 $employee = User::factory()
4 ->employee()
5 ->create();
6 
7 $task = [
8 'title' => fake()->title,
9 'description' => fake()->text(200),
10 'user_id' => $employee->id
11 ];
12 
13 // Acción y Confirmar
14 actingAs($employee)
15 ->post(route('tasks.store'), $task)
16 // La petición está prohibida
17 ->assertForbidden();
18 
19 // La tabla tasks no tiene registros
20 assertDatabaseCount('tasks', 0);
21});
  1. El administrador puede ver el detalle de todas las tareas.
1it('will show any task if the user is administrador', function () {
2 // Preparación
3 $admin = User::factory()
4 ->administrator()
5 ->create();
6 
7 $employee01 = User::factory()
8 ->employee()
9 ->has(Task::factory()->count(3))
10 ->create();
11 
12 // Acción y Confirmar
13 actingAs($admin)
14 ->get(route('tasks.show', $employee01->tasks->first()->id))
15 // La petición sea satisfactoria
16 ->assertOk()
17 // Nos retorne la vista task-show
18 ->assertViewIs('task-show')
19 // La vista tiene la información del task del empleado 1
20 ->assertViewHas('task', $employee01->tasks->first());
21});
  1. El empleado puede ver el detalle de su tarea.
1it('will show only the task related to the user if is an employee', function () {
2 // Preparación
3 $employee = User::factory()
4 ->employee()
5 ->has(Task::factory()->count(3))
6 ->create();
7 
8 // Acción y Confirmar
9 actingAs($employee)
10 ->get(route('tasks.show', $employee->tasks->first()->id))
11 // La petición sea satisfactoria
12 ->assertOk()
13 // Nos retorne la vista task-show
14 ->assertViewIs('task-show')
15 // La vista tiene la información del task del empleado logueado
16 ->assertViewHas('task', $employee->tasks->first());
17});
  1. El empleado no puede el detalle de una tarea de otro empleado.
1it('will not show the task related to other employees', function () {
2 // Preparación
3 $employee01 = User::factory()
4 ->employee()
5 ->has(Task::factory()->count(3))
6 ->create();
7 
8 $employee02 = User::factory()
9 ->employee()
10 ->has(Task::factory()->count(3))
11 ->create();
12 
13 // Acción y Confirmar
14 actingAs($employee01)
15 ->get(route('tasks.show', $employee02->tasks->first()->id))
16 ->assertForbidden();
17});

Ejecutamos los tests con el comando.

1./vendor/bin/pest

Resultado

Wau son varios tests, pero nos servirá cuando conforme al tiempo vayamos añadiendo nuevas funcionalidades ya que al ejecutar los tests veremos si hemos roto alguna funcionalidad.

Recuerda que al ejecutar los tests, por esta oportunidad lo estamos haciendo en nuestra propia BD así que probablemente los registros se hayan borrado. Para llenar nuevamente nuestra aplicación de prueba podemos ejecutar:

1php artisan db:seed

Y eso es todo amigos 😁, espero te haya sido de utilidad este tutorial y no olvides compartirlo con tus colegas!

Abajo te dejo el link del repositorio con el tutorial completo (branch main).

Repositorio en Github

Regístrate para que cada semana aprendas algo nuevo

Tendrás tutoriales, tips, conceptos y puedas convertirte en un artesano de todo el ecosistema Laravel.