# Autorisatie

Gates, policies, rollen en permissions, en teams die elkaars data niet zien, in Laravel 13

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, auth, security

Slides: <https://slides.mctantwerp.be/nl/the-agency/11-autorisatie>

<!-- slide 2 -->

## Agenda

1. Wat mag je
2. Gates
3. Policies
4. Waar je checkt
5. Rollen en permissions
6. Teams en 404

> **Toelichting:** Hoofdstuk 2 tot 4 zijn de basis die je in elk project gebruikt. Hoofdstuk 5 heb je nodig zodra er meer dan één soort gebruiker is, hoofdstuk 6 zodra meerdere klanten dezelfde app gebruiken. In een Agency-project is dat bijna altijd zo.

<!-- slide 3 -->

## Na deze les kan je

- Een gate schrijven voor een actie zonder model, en een policy voor de acties op een model
- Kiezen waar je een check zet: op de route, in de controller, in een Form Request of in middleware
- Knoppen tonen of verbergen met @can, en uitleggen waarom dat geen beveiliging is
- Rollen bewaren in een enum-kolom, en weten wanneer spatie/laravel-permission de moeite is
- Zorgen dat een team alleen zijn eigen records ziet, en kiezen tussen 403 en 404
- Met een Pest-test bewijzen dat een check werkt

> **Toelichting:** Het laatste punt is het belangrijkste. Een check die je niet test, ga je vroeg of laat kwijtraken bij een refactor. De opdracht op het einde vraagt daarom een test per statuscijfer.

<!-- slide 4 -->

## 1. Wat mag je

> **Toelichting:** Autorisatie is waar de meeste lekken in webapps zitten, niet in de login. Een login bouw je één keer met een starter kit, maar een check moet je bij elke nieuwe route opnieuw schrijven. Dit hoofdstuk toont hoe makkelijk je er één vergeet.

<!-- slide 5 -->

**Ingelogd is niet hetzelfde als alles mogen.**

Authenticatie zegt wie je bent, zie de les over authenticatie. Autorisatie beslist bij elke actie opnieuw wat die persoon mag.

> **Toelichting:** In Laravel staat autorisatie los van authenticatie: welke guard je ook gebruikt, sessie of Sanctum-token, gates en policies werken op dezelfde manier. Ze krijgen gewoon de ingelogde User.

<!-- slide 6 -->

### Het lek dat je niet ziet

Stel: een klantportaal met login via Microsoft en MFA, alles netjes. Je opent `/invoices/1041`, je eigen factuur, en verandert het nummer in 1040.

*InvoicesController.php*

```php
public function show(Invoice $invoice): View
{
    return view('invoices.show', ['invoice' => $invoice]);
}
```

Met deze controller zie je de factuur van iemand anders. De login werkt perfect, maar niemand checkt of die factuur van jou is.

> **Toelichting:** Dit is geen verzonnen randgeval. Nummers die oplopen zijn makkelijk te raden, en elke gebruiker kan de url aanpassen. Een uuid in plaats van een oplopend id maakt raden moeilijker, maar vervangt de check niet: een url kan ook gedeeld of gelekt worden.

<!-- slide 7 -->

### Wat route model binding niet doet

Route model binding haalt elke `Invoice` op die bestaat. Het weet niets over wie er ingelogd is. Een id in de url aanpassen om bij de data van een ander te komen heet IDOR, insecure direct object reference.

> [!WARNING]
> Broken access control staat op één in de [OWASP Top 10](https://owasp.org/Top10/). Bijna altijd gaat het om een check die gewoon ontbreekt. De rest van die lijst is de les over security.

Laravel geeft je twee gereedschappen om die check te schrijven: gates en policies. Alles in deze les staat in de [docs over authorization](https://laravel.com/docs/13.x/authorization).

> **Toelichting:** Route model binding scopet wel geneste routes als je dat vraagt, met scopeBindings, zie hoofdstuk 6. Maar dat checkt alleen of het kind bij de ouder hoort, niet of jij bij die ouder hoort. De check op de gebruiker schrijf je altijd zelf.

<!-- slide 8 -->

## 2. Gates

> **Toelichting:** Gates zijn de eenvoudigste vorm: een naam en een closure. Ze passen bij acties die niet bij één model horen. In een klein project zijn een paar gates genoeg, in een groter project gebruik je vooral policies.

<!-- slide 9 -->

### Een gate definiëren

*app/Providers/AppServiceProvider.php*

```php
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::define('viewAdminPanel', function (User $user) {
        return $user->is_admin;
    });
}
```

1. Gates komen in de `boot`-methode van AppServiceProvider, zodat ze bestaan voor er een request binnenkomt.
2. Een gate is een naam en een closure die true of false geeft. De closure krijgt altijd eerst de ingelogde gebruiker, die geef je zelf nooit mee. Is er niemand ingelogd, dan is het antwoord false en draait je closure niet eens. Namen in camelCase.

> **Toelichting:** Wil je de closure niet in AppServiceProvider, dan kan je een class meegeven: Gate::define('viewAdminPanel', [AdminPolicy::class, 'view']). Voor gasten kan een gate toch draaien als je de parameter nullable maakt, met ?User $user.

<!-- slide 10 -->

### Een gate gebruiken

```php
if (Gate::denies('viewAdminPanel')) {
    abort(403);
}

Gate::authorize('viewAdminPanel');

$request->user()->can('viewAdminPanel');

Route::get('admin', AdminController::class)
    ->can('viewAdminPanel');
```

1. De lange vorm: vragen en zelf stoppen met een 403.
2. Hetzelfde in één regel. `authorize` gooit een `AuthorizationException`, en daar maakt Laravel een 403 van.
3. Via de gebruiker. Handig als je het antwoord nodig hebt zonder te stoppen, zoals om een link te tonen.
4. Op de route, voor de controller draait. Dat is de plek waar je in één oogopslag ziet wat beschermd is.

> **Toelichting:** Er is ook Gate::allows, het omgekeerde van denies, en Gate::any en Gate::none om meerdere abilities tegelijk te checken. Voor een andere gebruiker dan de ingelogde gebruik je Gate::forUser($user)->allows(...).

<!-- slide 11 -->

### Een reden meegeven

```php
Gate::define('exportReports', function (User $user) {
    return $user->isManager()
        ? Response::allow()
        : Response::deny('Alleen managers exporteren rapporten.');
});
```

In plaats van true of false geef je een `Response` terug. De boodschap van `deny` komt in de 403-pagina en in de JSON van een API-request. Met `Gate::inspect('exportReports')` krijg je het hele antwoord, met de boodschap, zonder dat er een exception valt.

> **Toelichting:** Response::deny is ook handig in een API: de boodschap komt in de JSON-fout, en je frontend kan hem tonen. Hou de boodschap wel algemeen genoeg: ze mag uitleggen waarom iets niet mag, niet hoe je systeem vanbinnen werkt.

<!-- slide 12 -->

## 3. Policies

> **Toelichting:** Policies zijn het hart van autorisatie in Laravel. Bijna elke check in een project hoort in een policy, omdat het meestal over een model gaat: mag deze gebruiker dit project, deze factuur, deze bestelling.

<!-- slide 13 -->

### Gates en policies

#### Gate

- Een closure met een naam, in AppServiceProvider
- Voor acties zonder model, zoals het admin-panel of een export
- Een paar gates zijn prima, maar ze staan allemaal in één bestand

#### Policy

- Een class per model, met een methode per actie: view, update, delete
- Staat in `app/Policies`, en Laravel vindt hem zelf
- De docs vergelijken het met routes en controllers: zodra je update-project en delete-project schrijft, wil je een ProjectPolicy

> **Toelichting:** Je kan ze ook mengen: een gate die een policy-methode aanroept, of een policy die een gate checkt met $user->can(). Kies per regel de plek waar een collega hem het eerst zou zoeken.

<!-- slide 14 -->

### Een policy maken

```console
$ php artisan make:policy ProjectPolicy --model=Project
INFO  Policy [app/Policies/ProjectPolicy.php] created successfully.
```

Met `--model` krijg je meteen `viewAny`, `view`, `create`, `update`, `delete`, `restore` en `forceDelete`. Gooi weg wat je niet gebruikt: een lege methode die true geeft, is een open deur.

Een policy wordt door de service container gemaakt, dus een dependency in de constructor mag.

> **Toelichting:** Er is ook viewAny, voor de lijst: mag je de index van projecten openen. Die krijgt geen model, net als create. Voor restore en forceDelete heb je alleen iets aan als je model soft deletes gebruikt.

<!-- slide 15 -->

### Een policy

*app/Policies/ProjectPolicy.php*

```php
public function view(User $user, Project $project): bool
{
    return $user->team_id === $project->team_id;
}

public function delete(User $user, Project $project): bool
{
    if ($user->team_id !== $project->team_id) {
        return false;
    }

    return $user->isManager();
}
```

1. Elke methode krijgt de gebruiker en het model. Bekijken mag iedereen van hetzelfde team.
2. Verwijderen is strenger. Eerst weg met wat niet mag, dan de regel die overblijft: alleen een manager. Een early return leest makkelijker dan één lange voorwaarde met `&&`.

> **Toelichting:** Een policy-methode kan ook een Response teruggeven in plaats van een bool, zoals je bij gates zag. Hoofdstuk 6 gebruikt dat om een 404 te geven in plaats van een 403. isManager() is een methode op je User, die zie je in hoofdstuk 5.

<!-- slide 16 -->

### Hoe Laravel de policy vindt

| Je schrijft | Laravel roept aan |
| --- | --- |
| `$user->can('update', $project)` | `ProjectPolicy::update($user, $project)` |
| `$user->can('create', Project::class)` | `ProjectPolicy::create($user)` |
| `$user->can('exportReports')` | De gate exportReports |

Laravel zoekt de policy op naam: `App\Models\Project` hoort bij `App\Policies\ProjectPolicy`. Voor `create` is er nog geen model, dus geef je de class mee. Staan je models in domeinmappen, zoals in de les over DDD, dan klopt die conventie niet meer en zet je het attribuut op het model:

```php
#[UsePolicy(ProjectPolicy::class)]
class Project extends Model
```

> **Toelichting:** Auto-discovery zoekt eerst in app/Models/Policies en dan in app/Policies. Wil je een eigen zoekregel, dan is er Gate::guessPolicyNamesUsing() in AppServiceProvider. Gate::policy(Project::class, ProjectPolicy::class) koppelt ze met de hand.

<!-- slide 17 -->

### before in een policy

```php
public function before(
    User $user,
    string $ability,
): ?bool {
    if ($user->is_admin) {
        return true;
    }

    return null;
}
```

1. `before` draait voor elke andere methode van de policy, maar alleen als de policy een methode heeft met de naam van de ability.
2. Geeft het true, dan mag het, en de gewone methode draait niet meer. Handig voor een admin die alles mag.
3. null betekent: ik heb niets te zeggen, laat de gewone methode beslissen. Geef je false, dan weiger je alles, voor iedereen die geen admin is.

> **Toelichting:** Een before in een policy is handig als je admin-regel alleen voor dat ene model geldt. Moet een admin overal alles kunnen, dan is Gate::before op de volgende slide eenvoudiger. De parameter $ability bevat de naam van de gecheckte actie, zoals update, als je voor één actie een uitzondering wil maken.

<!-- slide 18 -->

### Gate::before voor de hele app

*app/Providers/AppServiceProvider.php*

```php
Gate::before(function (User $user, string $ability) {
    if ($user->is_admin) {
        return true;
    }

    return null;
});
```

Hetzelfde idee, maar voor elke gate en elke policy in je app. Zo maak je een super admin. Dezelfde regel als in een policy: true laat alles toe, false weigert alles, null laat de gewone check beslissen.

> **Toelichting:** Er is ook Gate::after, dat draait na de gewone check. Dat is zeldzaam, maar handig om beslissingen te loggen. Let op: een super admin via Gate::before kan echt alles, ook dingen die je voor iedereen had willen verbieden, zoals een project van een klant definitief verwijderen.

<!-- slide 19 -->

### Je before() in ProjectPolicy geeft false terug voor iedereen die geen admin is. Wat gebeurt er?

- A. Niets, false wordt genegeerd
- B. Alleen admins kunnen nog iets met projecten, de andere methodes draaien nooit
- C. Laravel gooit een exception
- D. Alleen delete wordt geweigerd

**Antwoord:** B. Alleen admins kunnen nog iets met projecten, de andere methodes draaien nooit

Een before die iets anders dan null teruggeeft, beslist. false betekent: geweigerd, en view of update krijgen niet eens de kans. Het lijkt alsof false hetzelfde is als niets zeggen, maar daarvoor is null. Een exception komt er niet: iedereen krijgt gewoon 403.

> **Toelichting:** Een tweede valkuil in dezelfde hoek: een policy-methode die niets teruggeeft. In PHP is dat null, en voor Laravel betekent dat geweigerd. Met een return type bool op elke methode vangt PHP die fout op.

<!-- slide 20 -->

## 4. Waar je checkt

> **Toelichting:** Een regel in een policy helpt niets als niemand hem aanroept. Dit hoofdstuk gaat over waar je de check zet, zodat hij bij elke request draait, en over de plekken waar een check alleen maar lijkt te beschermen.

<!-- slide 21 -->

### Op de route

*routes/web.php*

```php
Route::put('projects/{project}', [ProjectsController::class, 'update'])
    ->can('update', 'project');

Route::post('projects', [ProjectsController::class, 'store'])
    ->can('create', Project::class);
```

1. De tweede parameter is de naam van de route-parameter, niet het model. Laravel geeft de policy het model dat route model binding net ophaalde. Mag het niet: 403.
2. Voor `create` is er geen model, dus geef je de class mee. Hetzelfde als `->middleware('can:create,App\Models\Project')`, maar leesbaarder.

> **Toelichting:** Bij Route::resource zet je de check niet per route. Daar gebruik je het Authorize-attribuut in de controller, of middlewareFor op de resource-route. Controleer met php artisan route:list -v of elke route de middleware heeft die je verwacht.

<!-- slide 22 -->

### Gate::authorize in de controller

```php
public function update(
    UpdateProjectRequest $request,
    Project $project,
): RedirectResponse {
    Gate::authorize('update', $project);

    // ...
}
```

Gebruik dit als de check afhangt van iets dat je pas in de controller weet, zoals een waarde uit de request.

Oude tutorials schrijven `$this->authorize()`. Dat komt uit de trait `AuthorizesRequests`, en de lege base Controller van een nieuw Laravel-project heeft die niet meer. `Gate::authorize` doet hetzelfde.

> **Toelichting:** Gate::authorize geeft ook de boodschap van een Response::deny door naar de 403-pagina. Heb je een model nodig dat niet in de route staat, zoals een project uit de request-body, dan is de controller de enige plek waar je het kan checken.

<!-- slide 23 -->

### Het Authorize-attribuut

```php
use Illuminate\Routing\Attributes\Controllers\Authorize;

#[Authorize('update', 'project')]
public function update(Project $project): RedirectResponse
{
    // ...
}
```

Nieuw in Laravel 13: het attribuut zet de `can` middleware op die ene methode. Dezelfde parameters als `->can()` op de route. Handig als je routes met `Route::resource` maakt en dus geen aparte regel per actie hebt. Zie de [docs over controllers](https://laravel.com/docs/13.x/controllers#authorization-attributes).

> **Toelichting:** Het attribuut accepteert ook een class voor create, zoals #[Authorize('create', Project::class)], en een array met een class en een route-parameter voor geneste resources. Middleware kan je op dezelfde manier toevoegen met het attribuut Middleware.

<!-- slide 24 -->

### In een Form Request

*app/Http/Requests/UpdateProjectRequest.php*

```php
class UpdateProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('project'));
    }
}
```

Handig als je voor die actie toch al een Form Request hebt: rechten en validatie in één bestand. `authorize` draait voor `rules`. Iemand zonder rechten krijgt dus 403 en geen validatiefouten die verklappen welke velden er bestaan.

> **Toelichting:** Laat je authorize() weg in een Form Request, dan is alles toegestaan. Bij php artisan make:request staat er standaard return false in de stub, zodat je het niet vergeet. Een 403 tijdens het testen van een nieuwe Form Request komt vaak daarvandaan.

<!-- slide 25 -->

### In Blade

```html
@can('update', $project)
    <a href="{{ route('projects.edit', $project) }}">Bewerken</a>
@endcan

@can('viewAdminPanel')
    <a href="{{ route('admin') }}">Admin</a>
@endcan
```

`@can` toont een stuk HTML alleen als de gebruiker de ability heeft, met dezelfde policies en gates. Er is ook `@cannot`, en `@canany` voor meerdere abilities tegelijk.

> [!WARNING]
> Een verborgen knop beveiligt niets. De route moet zelf ook checken.

> **Toelichting:** Een verborgen knop is wel een goede gewoonte: een gebruiker die op een knop klikt en een 403 krijgt, denkt dat je app kapot is. Zet de check dus op twee plaatsen: in de view voor de gebruiker, op de route voor de veiligheid.

<!-- slide 26 -->

### In Inertia

```php
return Inertia::render('projects/show', [
    'project' => $project,
    'can' => [
        'update' => $request->user()->can('update', $project),
    ],
]);
```

React of Vue kan je policies niet aanroepen. Je rekent het antwoord uit op de server en geeft het mee als prop. Voor wat op elke pagina staat, zoals de link naar het admin-panel, deel je het in `HandleInertiaRequests`.

> **Toelichting:** Deel niet de hele lijst rechten van een gebruiker naar de frontend als je er maar twee nodig hebt. Alles in de props kan iedereen lezen in de paginabron. Rechten zijn geen geheim, maar een lijst van alle permissions vertelt wel veel over je systeem.

<!-- slide 27 -->

### Eigen middleware

```php
public function handle(Request $request, Closure $next): Response
{
    if ($request->user()?->deactivated_at) {
        Auth::logout();
        return redirect()->route('login')->withErrors([
            'email' => 'Dit account is gedeactiveerd.',
        ]);
    }

    return $next($request);
}
```

`EnsureUserIsActive`: zet een beheerder `deactivated_at`, dan is die persoon bij de volgende klik buiten, ook met een geldige sessie.

> **Toelichting:** Je maakt de class met php artisan make:middleware EnsureUserIsActive. De ?-> zorgt dat een gast gewoon doorgaat: de auth middleware beslist of gasten hier mogen komen, niet deze.

<!-- slide 28 -->

### Middleware registreren

*bootstrap/app.php*

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [EnsureUserIsActive::class]);
})
```

Met `web(append:)` draait de middleware op elke route in `routes/web.php`. Middleware is de plek voor checks die voor een hele groep routes gelden en niets met één model te maken hebben. Voor één actie op één model neem je een policy. Zie de [docs over middleware](https://laravel.com/docs/13.x/middleware).

> **Toelichting:** Wil je de middleware alleen op sommige routes, geef hem dan een alias met $middleware->alias() en gebruik die naam op de route. De volgorde van middleware telt: deze moet na de sessie draaien, anders is er nog geen gebruiker. In de web-groep is dat vanzelf zo.

<!-- slide 29 -->

### Welke laag waarvoor

| Waar | Waarvoor |
| --- | --- |
| Middleware | Een hele groep routes, zoals ingelogd of een account dat actief is |
| Route `can` of `#[Authorize]` | Eén actie op één model, zichtbaar bij de route |
| Form Request `authorize()` | Een actie die toch al een Form Request heeft |
| `Gate::authorize` | In de controller, als de check afhangt van wat je daar pas weet |
| Policy of gate | De regel zelf, op één plek |
| `@can` en props | Knoppen tonen of verbergen, geen beveiliging |

Een goede gewoonte: de regel altijd in een policy, de check op de route of met het attribuut.

> **Toelichting:** In een review is dit de vraag die je bij elke nieuwe route stelt: waar staat de check. Kan je geen antwoord geven, dan is er geen. Het enige echte bewijs is een test per route, zoals op het einde van deze les.

<!-- slide 30 -->

### Je verbergt de verwijderknop met @can('delete', $project), en de route heeft geen check. Wat gebeurt er als een member een DELETE naar /projects/12 stuurt?

- A. 403, want @can blokkeert het
- B. Het project wordt verwijderd
- C. 404, want de knop bestaat niet
- D. 419, want het CSRF-token ontbreekt

**Antwoord:** B. Het project wordt verwijderd

@can bepaalt alleen wat er in de HTML staat. Met de devtools, curl of een oude pagina stuurt iedereen die request toch. Het lijkt alsof @can de actie beschermt, maar de server checkt niets. Een 419 komt er ook niet: de member is gewoon ingelogd en heeft een geldig CSRF-token.

> **Toelichting:** Een DELETE sturen zonder knop is makkelijker dan je denkt: in de devtools kan je een formulier aanpassen, en met fetch() in de console stuur je elke request die je wil, met je eigen cookies erbij.

<!-- slide 31 -->

## 5. Rollen en permissions

> **Toelichting:** Rollen zijn een manier om rechten te groeperen, zodat je niet per gebruiker hoeft te beslissen. De vraag in elk project is hoeveel flexibiliteit de klant nodig heeft, want elke laag extra maakt het systeem moeilijker om te testen.

<!-- slide 32 -->

### Een kolom of een package

#### Een enum-kolom

- Een role-kolom op users, met een enum-cast
- Vaste rollen die jij beslist, in code
- De regels staan in je policies
- Genoeg voor de meeste Agency-projecten

#### spatie/laravel-permission

- Rollen en permissions in de database
- Aan te passen zonder deploy
- Een gebruiker kan meerdere rollen hebben
- De moeite als de klant zelf rollen wil maken of rechten wil aanvinken

> **Toelichting:** Begin met een enum-kolom als je twijfelt. Overstappen naar het package later is een migratie en een seeder, en je policies veranderen weinig als ze met permissions werken. Omgekeerd, een package uit een project halen, is meer werk.

<!-- slide 33 -->

### Rollen als enum

- *app/Enums/Role.php*

  ```php
  enum Role: string
  {
      case Admin = 'admin';
      case Manager = 'manager';
      case Member = 'member';
      case Client = 'client';
  }
  ```
- *app/Models/User.php*

  ```php
  protected function casts(): array
  {
      return ['role' => Role::class];
  }

  public function isManager(): bool
  {
      return $this->role === Role::Manager;
  }
  ```

De cast maakt van de string in de database een `Role`. Een tikfout zoals `'manger'` kan niet meer, en je editor vult de rollen aan.

> **Toelichting:** Een enum met string-waarden bewaar je in een gewone string-kolom, met een default zoals member in de migratie. Je kan ook methodes op de enum zetten, zoals label() voor een Nederlandse naam in de UI.

<!-- slide 34 -->

### spatie/laravel-permission installeren

```console
$ composer require spatie/laravel-permission
$ php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
$ php artisan migrate
```

Dat geeft je tabellen voor rollen, permissions en de koppelingen ertussen. Daarna zet je de trait `HasRoles` op je User. Zie de [docs van het package](https://spatie.be/docs/laravel-permission).

> **Toelichting:** Het package ondersteunt ook teams: rollen die per team verschillen, zoals manager in het ene team en member in het andere. Dat zet je aan met teams in config/permission.php, voor je de migratie draait.

<!-- slide 35 -->

### Rollen en permissions

```php
Permission::create(['name' => 'projects.delete']);

Role::create(['name' => 'manager'])
    ->givePermissionTo('projects.delete');

$user->assignRole('manager');

$user->can('projects.delete');
```

1. Een permission is één recht.
2. Een rol bundelt permissions. Dit hoort in een seeder, niet met de hand in productie.
3. Een gebruiker krijgt een rol, en daarmee alle permissions van die rol.
4. Checken doe je met het gewone `can`. Het package hangt zijn permissions aan de Gate, dus `@can` en je policies werken gewoon.

> **Toelichting:** Waarom can() werkt zonder dat je een gate definieert: het package registreert zelf een Gate::before die kijkt of de gebruiker de permission heeft. Heeft hij ze, dan true, anders null, en dan beslissen je gewone gates en policies.

<!-- slide 36 -->

### Middleware voor rollen

*bootstrap/app.php*

```php
$middleware->alias([
    'role' => RoleMiddleware::class,
    'permission' => PermissionMiddleware::class,
]);
```

*routes/web.php*

```php
Route::delete('projects/{project}', [ProjectsController::class, 'destroy'])
    ->middleware('permission:projects.delete');
```

De middleware van het package registreer je zelf als alias. De classes staan in `Spatie\Permission\Middleware`.

> **Toelichting:** De middleware checkt alleen de permission, niet of het project bij jouw team hoort. Voor dat laatste heb je nog altijd een policy nodig. De middleware is vooral handig voor hele secties, zoals een admin-zone.

<!-- slide 37 -->

### Check permissions, geen rollen

- Schrijf `@can('projects.delete')` in plaats van `@role('manager')`.
- Stel: de klant wil een rol teamlead die bijna hetzelfde mag als een manager. Met permissions voeg je een rol toe in de seeder. Met rol-checks zoek je door de hele codebase.
- `$user->can()` gaat via de Gate, dus je policies en `Gate::before` blijven werken.
- Een super admin maak je met `Gate::before`: true voor die rol, null voor de rest.

> **Toelichting:** Het package kent ook directe permissions op een gebruiker, zonder rol. Gebruik dat spaarzaam: één uitzondering is prima, maar tien gebruikers met elk hun eigen rechten maakt het onmogelijk om nog te zien wie wat mag.

<!-- slide 38 -->

### Wanneer is spatie/laravel-permission de moeite?

- A. Altijd, een rol-kolom is slechte praktijk
- B. Als de klant zelf rollen wil maken en rechten wil aanvinken
- C. Zodra je meer dan twee rollen hebt
- D. Alleen met een SPA

**Antwoord:** B. Als de klant zelf rollen wil maken en rechten wil aanvinken

Rechten die veranderen zonder deploy, of die de klant zelf beheert, horen in de database, en daar is het package voor. Het aantal rollen maakt niet uit: vier vaste rollen passen perfect in een enum en je policies. Een rol-kolom is dus geen slechte praktijk, alleen minder flexibel.

> **Toelichting:** Een tussenweg die je ook ziet: een enum voor de rol, en een methode op de enum die zegt welke permissions erbij horen. Dat geeft je de leesbaarheid van permissions zonder extra tabellen, zolang de klant ze niet zelf wil aanpassen.

<!-- slide 39 -->

## 6. Teams en 404

> **Toelichting:** Multi-tenant fouten zijn de ergste lekken in een Agency-project: niet één gebruiker ziet iets te veel, maar een klant ziet de data van een andere klant. Dat is een datalek dat je aan de klant moet melden.

<!-- slide 40 -->

### Eén app, veel klanten

- Eén codebase en één database voor meerdere klanten. Elke rij hoort bij een team, via een team_id.
- Een team is een klant, of een afdeling van een klant.
- Elke query moet op dat team filteren. Eén vergeten where en een klant ziet de data van een andere.
- De starter kits hebben een teams-optie: een gebruiker zit in meerdere teams en heeft een huidig team. Hier houden we het bij één `team_id` per gebruiker. [Docs over teams](https://laravel.com/docs/13.x/starter-kits#teams).

> **Toelichting:** Er zijn packages voor multi-tenancy, zoals stancl/tenancy, met een aparte database per klant. Dat is zelden nodig voor een Agency-project en maakt deploys en migraties een stuk zwaarder. Een team_id op elke tabel is meestal genoeg.

<!-- slide 41 -->

### Begin bij het team

- *Fout*

  ```php
  Project::latest()->get();

  Project::findOrFail($id);
  ```
- *Goed*

  ```php
  $team = $request->user()->team;

  $team->projects()->latest()->get();

  $team->projects()->findOrFail($id);
  ```

Links zie je de projecten van iedereen. Rechts zoekt `findOrFail` alleen binnen de relatie, dus een project van een ander team vind je niet eens: 404. Je hoeft de check niet te onthouden, hij zit in de query.

> **Toelichting:** Hetzelfde principe werkt voor store: maak het project via de relatie, $team->projects()->create(...), dan krijgt het vanzelf het juiste team_id. Een team_id uit een hidden field in het formulier moet je nooit vertrouwen.

<!-- slide 42 -->

### Geneste routes

```php
Route::scopeBindings()->group(function () {
    Route::get('teams/{team}/projects/{project}', [ProjectsController::class, 'show']);
});
```

Met `scopeBindings` zoekt Laravel het project via `$team->projects()`. Hoort project 12 niet bij dat team, dan is het 404.

> [!WARNING]
> Dat beschermt alleen de koppeling tussen team en project. Of de gebruiker in dat team zit, check je nog altijd zelf, met een policy of middleware.

> **Toelichting:** Zonder scopeBindings zou /teams/1/projects/12 werken, ook als project 12 bij team 2 hoort. Met een custom sleutel, zoals {project:slug}, scopet Laravel geneste routes al vanzelf.

<!-- slide 43 -->

### Een global scope

*app/Models/Project.php*

```php
#[ScopedBy([TeamScope::class])]
class Project extends Model
```

*app/Models/Scopes/TeamScope.php*

```php
class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('team_id', Auth::user()?->team_id);
    }
}
```

Elke query op Project krijgt nu die where erbij, ook als je hem vergeet.

> **Toelichting:** In oudere code zie je de scope in de booted-methode van het model, met static::addGlobalScope(new TeamScope). Het effect is hetzelfde.

<!-- slide 44 -->

### Relatie of scope

- Via de relatie, `$team->projects()`, zie je in de code waar het filter zit. Een scope zit verstopt in het model.
- Een scope vergeet je nooit, ook niet in een nieuwe query die een collega schrijft.
- In een job of command zonder ingelogde gebruiker filtert een scope op null. Daar gebruik je `Project::withoutGlobalScope(TeamScope::class)` en filter je zelf.
- Vuistregel: de relatie, tenzij het hele project team-gebonden is. Global scopes komen terug in de les over Eloquent-modellen.

> **Toelichting:** Een scope die zonder gebruiker niets filtert, is nog gevaarlijker: in een job zie je dan alles, en dat valt pas op als er iets misgaat. Als je een scope schrijft, laat hem dan in twijfel liever niets tonen dan alles.

<!-- slide 45 -->

### 403 of 404

|   | Wanneer | Voorbeeld |
| --- | --- | --- |
| **403** | Je mag weten dat het bestaat, je mag het alleen niet doen | Een member die een project van zijn eigen team wil verwijderen |
| **404** | Het is van iemand anders, en dat het bestaat gaat je niets aan | Een project van een ander team, een factuur van een andere klant |

Een 403 op `/projects/1040` zegt: hier staat iets. Wie de nummers afloopt, weet zo hoeveel klanten je hebt en welke nummers bestaan.

> **Toelichting:** denyAsNotFound is een kortere schrijfwijze voor Response::denyWithStatus(404). Met denyWithStatus kies je elke status die je wil.

<!-- slide 46 -->

### denyAsNotFound

```php
public function delete(User $user, Project $project): Response
{
    if ($user->team_id !== $project->team_id) {
        return Response::denyAsNotFound();
    }

    return $user->isManager()
        ? Response::allow()
        : Response::deny('Alleen managers verwijderen.');
}
```

1. Een ander team: weigeren met een 404, alsof het project niet bestaat.
2. Hetzelfde team, maar geen manager: een 403, met een boodschap die zegt waarom.

> **Toelichting:** Denk bij 404 ook aan de lijst: toont de index alleen je eigen projecten, dan is een 404 op de detailpagina logisch. Een project dat je niet in de lijst ziet, bestaat voor jou niet.

<!-- slide 47 -->

### Wie mag wat

Kies een gebruiker en een request en voorspel 200, 403 of 404. Probeer Yusuf met DELETE, dan Jonas: welke check beslist?

Een rechtenmatrix met 4 rollen en 5 abilities. Kies een gebruiker en een request, en je ziet of Laravel 200, 403 of 404 antwoordt en welke check besliste.
- admin: alles, via Gate::before
- manager: bekijken, bewerken, verwijderen, facturen
- member: bekijken, bewerken
- client: bekijken, facturen
Een record van een ander team geeft 404, een ability die je rol niet heeft 403.

> **Toelichting:** Kijk vooral naar de volgorde van de checks rechts: Gate::before, dan het team, dan de rol. Die volgorde bepaalt het statuscijfer. Probeer daarna Sam op alle requests en Els op PUT, en voorspel telkens eerst.

<!-- slide 48 -->

### Jonas is manager bij Fietsbel en opent /projects/12, een project van Brouwerij. Wat moet hij krijgen?

- A. 200, managers mogen projecten bekijken
- B. 403 Forbidden
- C. 404 Not Found
- D. 302 naar zijn dashboard

**Antwoord:** C. 404 Not Found

Zijn rol mag projecten bekijken, maar niet die van een ander team. 403 is verleidelijk, want hij mag het inderdaad niet, maar met een 403 bevestig je dat project 12 bestaat. denyAsNotFound, of een query via zijn team, geeft 404. De 200 vergeet dat de rol alleen geldt binnen je eigen team.

> **Toelichting:** In een API geldt hetzelfde. Een 404 voor een record van een andere klant is correct, ook als je frontend dan een algemene foutmelding toont. Zet in je logs wel genoeg om te zien dat het een geweigerde toegang was, voor als een klant belt.

<!-- slide 49 -->

### Test: een ander team

```php
it('hides projects of other teams', function () {
    $project = Project::factory()->create();

    $this->actingAs(User::factory()->create())
        ->delete(route('projects.destroy', $project))
        ->assertNotFound();
});
```

Een vergeten check zie je niet in de browser, want je test altijd als jezelf. Een test logt in als iemand anders. Hier: een gebruiker uit een ander team, want de factory maakt voor elk een nieuw team.

> **Toelichting:** Deze test gaat ervan uit dat je factory voor elke nieuwe gebruiker een nieuw team maakt, met team_id => Team::factory(). Doet je factory dat niet, dan zitten beide in hetzelfde team en faalt de test om de verkeerde reden.

<!-- slide 50 -->

### Test: een rol die het niet mag

```php
it('forbids members to delete projects', function () {
    $member = User::factory()->create(['role' => Role::Member]);
    $project = Project::factory()->for($member->team)->create();

    $this->actingAs($member)
        ->delete(route('projects.destroy', $project))
        ->assertForbidden();
});
```

Zelfde team, verkeerde rol: 403. Schrijf per policy-methode minstens één test voor wie mag en één voor wie niet mag. Pest en factories zijn de les over testen.

> **Toelichting:** Test ook de andere kant: een manager die wel mag verwijderen krijgt een redirect, en het project is weg met assertModelMissing. Een test die alleen weigeringen checkt, blijft groen als je per ongeluk alles weigert.

<!-- slide 51 -->

### Opdracht: Rechten in je Agency-project

Neem het belangrijkste model van je project, zoals projecten of bestellingen.

1. Voeg een team_id en een role toe, met een enum-cast (slide 33)
2. Schrijf een policy met view, update en delete, met denyAsNotFound voor een ander team (slide 46)
3. Hang de checks aan je routes met can, en verberg de knoppen met @can (slide 21 en 25)
4. Klaar als een Pest-test per statuscijfer groen is: 200 voor je eigen team, 403 voor een rol die het niet mag, 404 voor een ander team

Vast? De [docs over authorization](https://laravel.com/docs/13.x/authorization) hebben een voorbeeld voor elke stap. Vergeet de index niet: ook de lijst mag alleen je eigen team tonen.

> **Toelichting:** Begin met de test voor 404, want die faalt nu waarschijnlijk: zonder policy kan iedereen elk project openen. Schrijf daarna de policy tot de test groen wordt. Zo weet je zeker dat de test echt iets checkt.

## Begrippen

- **Broken access control**: Een gebruiker kan iets zien of doen wat niet voor hem bedoeld is. De nummer één in de OWASP Top 10, en bijna altijd een vergeten check. (slide 6)
- **IDOR**: Insecure direct object reference: een id in de url aanpassen en zo bij de data van een ander komen, zoals /invoices/1040 in plaats van 1041. Route model binding checkt dat niet voor jou. (slide 7)
- **Gate**: Een closure met een naam die zegt of een gebruiker iets mag, gedefinieerd met Gate::define in AppServiceProvider. Voor acties zonder model, zoals het admin-panel of een export. (slide 9)
- **Policy**: Een class met de regels voor één model, met een methode per actie: view, update, delete. Maak je met php artisan make:policy ProjectPolicy --model=Project. (slide 15)
- **Policy discovery**: Laravel vindt de policy van een model zelf als de naam klopt: App\\Models\\Project hoort bij App\\Policies\\ProjectPolicy. Anders zet je #\[UsePolicy\] op het model. (slide 16)
- **before**: Een methode in een policy die vóór elke andere methode draait. true laat alles toe, false weigert alles, null laat de gewone methode beslissen. Gate::before doet hetzelfde voor elke check in de app. (slide 17)
- **can middleware**: Checkt een ability voor de controller draait, met ->can('update', 'project') op een route. Weigert met 403. (slide 21)
- **Authorize-attribuut**: Het attribuut #\[Authorize('update', 'project')\] op een controller-methode in Laravel 13. Een kortere schrijfwijze voor de can middleware. (slide 23)
- **Gate::authorize**: Checkt een ability en gooit een AuthorizationException als het niet mag. Laravel maakt daar een 403 van, of de status die de policy meegeeft. (slide 22)
- **Form Request authorize**: De methode authorize() in een Form Request. Geeft ze false terug, dan krijgt de gebruiker 403 en draait je controller niet. (slide 24)
- **@can**: Blade-directive die een stuk HTML alleen toont als de gebruiker een ability heeft. Verbergt een knop, maar beveiligt niets: de route moet zelf ook checken. (slide 25)
- **Middleware**: Een laag rond je routes die elke request ziet voor de controller. Goed voor checks die voor een hele groep routes gelden, zoals ingelogd zijn of een account dat niet gedeactiveerd is. (slide 27)
- **Rol**: Een groep gebruikers met dezelfde rechten, zoals manager of member. Een rol zegt wie je bent in het project, een permission wat je mag doen. (slide 32)
- **Permission**: Eén recht, zoals projects.delete. Met spatie/laravel-permission hang je permissions aan rollen en check je ze met $user->can(). (slide 31)
- **spatie/laravel-permission**: Package dat rollen en permissions in de database bewaart, met de trait HasRoles op je User. De moeite als de klant zelf rollen wil beheren, anders is een enum-kolom genoeg. (slide 34)
- **Super admin**: Een rol die alles mag, via Gate::before die true teruggeeft. Geef null terug voor alle anderen, niet false, anders breek je elke policy. (slide 18)
- **Multi-tenancy**: Eén app en één database voor meerdere klanten of teams, waarbij elke rij bij een team hoort. Elke query moet op dat team filteren, één vergeten where is een datalek. (slide 40)
- **Global scope**: Een where die Laravel aan elke query op een model toevoegt, zoals where team_id. Hang je aan een model met #\[ScopedBy\]. Handig voor teams, verraderlijk in jobs zonder ingelogde gebruiker. (slide 43)
- **403 Forbidden**: Je bent ingelogd, maar je mag dit niet. Gebruik het als de gebruiker mag weten dat het record bestaat. (slide 45)
- **denyAsNotFound**: Response::denyAsNotFound() in een policy of gate: weigeren met een 404 in plaats van een 403. Voor records van een ander team, waarvan je niet eens wil verklappen dat ze bestaan. (slide 46)

## Links en bronnen

### In de presentatie

- [OWASP Top 10](https://owasp.org/Top10/) (slide 7): Broken access control staat op één
- [Authorization](https://laravel.com/docs/13.x/authorization) (slide 7, slide 51): Gates, policies, discovery, UsePolicy, before, can middleware, @can, denyAsNotFound
- [Controllers: authorization attributes](https://laravel.com/docs/13.x/controllers#authorization-attributes) (slide 23): Het Authorize-attribuut en middleware-attributen in Laravel 13
- [Middleware](https://laravel.com/docs/13.x/middleware) (slide 28): Eigen middleware en registreren in bootstrap/app.php
- [spatie/laravel-permission](https://spatie.be/docs/laravel-permission) (slide 34): HasRoles, rollen en permissions, middleware, super admin met Gate::before
- [Starter kits: teams](https://laravel.com/docs/13.x/starter-kits#teams) (slide 40): Teams en de huidige team in de url

### Bronnen

- [Form request authorization](https://laravel.com/docs/13.x/validation#authorizing-form-requests): authorize() in een Form Request
