Use when reviewing authorization / access control in a Laravel project — find IDOR / broken object-level authorization (BOLA), audit which routes are unprotected, check policy / gate coverage, or sanity-check a new endpoint in a PR. Walks the authorization chain of every HTTP route (middleware → authorize/policy/gate → query scoping → API Resource output), anchors every finding to real `php artisan route:list --json` output plus a cited `file:line`, classifies by evidence, and produces a per-route coverage map. Read-only / advise-only — never edits code.
php artisan boost:add-skill pekral/ai-olympus
Save this content to: .claude/skills/laravel-authorization-review/SKILL.md
## Constraints
- Apply `@rules/php/core-standards.md`
- Apply `@rules/laravel/laravel.md` and `@rules/laravel/architecture.md`
- Apply `@rules/security/backend.md` — *Database* (authentication & authorization, least privilege) and *Safe Validation & Error Messages* (a 403-vs-404 distinction that confirms a resource exists is itself an authorization-granularity leak)
- Apply `@rules/code-review/general.md` — map every finding onto the CR severity scale (Critical / Moderate) so this skill plugs into a Laravel CR run
- **Advise-only.** Reads files and runs one read-only command (`php artisan route:list --json`). Never edits routes, controllers, policies, or any source; emits a report plus fix sketches for a human to apply.
- Apply `@rules/reports/general.md` — output in English, or in the language the project manifest sets in `language.github` when the findings fold into the GitHub PR comment (*Project override — `language.github` in the manifest*).
---
## Modes
This skill runs in one of two modes, selected by the caller via `MODE` (default `audit`):
- **`audit` (default)** — the full walk below: every application route in the inventory, the per-route coverage map, and the whole report template. Every section behaves as written unless it is explicitly flagged for `MODE=cr`.
- **`cr` (read-only lens — invoked by `@skills/code-review/SKILL.md`, `code-review-github`, `code-review-jira`, and `code-review-bugsnag` when the diff touches an authorization surface)** — **never modify a route, a controller, a policy, or any other file, never author a test, never stage / commit / push, never run a fixer or a checker, and never chain a follow-up review.** The one command the lens runs stays the read-only `php artisan route:list --json` of step 1. Scope the walk to the routes the PR diff touches — step 1.3's intersection with the changed files is mandatory here, not optional — and return the findings as markdown carrying the reproducer fields the CR folds into its standard Critical / Moderate buckets. Emit every fix as a written snippet, never applied.
> **What this lens owns in a CR:** object-level authorization — whether the record a changed route reads, returns, or writes is scoped to the actor who asked for it (**IDOR / BOLA**), and whether that route's middleware → policy / gate → scoping → Resource-output chain authorizes at all. It reports on the routes the diff touched and never on a pre-existing route it did not.
> **What it does not render on a CR:** the per-route coverage map and the standalone report header of `templates/report.md`. A CR carries findings, and the coverage map is an audit artifact.
> **Evidence still governs severity.** A route whose exposure rests on a point the code does not settle is reported at the severity its verified facts support and is never promoted to close the gap — *Evidence & honesty* below is unchanged by this mode. When `route:list` will not boot, the lens runs on the static fallback of step 1.4, reports it as not verified, and moves every structural finding to the open-point class.
---
## Scope
> SAST tells you where data flows. This tells you where it flows to the **wrong user**.
This skill is the judgment layer for the one category automated scanners structurally
cannot do: **broken object-level authorization (IDOR / BOLA)** — #1 in the OWASP API
Security Top 10. Taint scanners trace untrusted *input*; they cannot decide whether
`Order::find($id)` *should* have been scoped to the current user. That is a question
about **intent**, reasoned across middleware, controller, policy, and query.
The skill is trustworthy because every finding traces to a **ground-truth anchor**:
`php artisan route:list --json` is the deterministic inventory of every endpoint and
its merged middleware. If you cannot point to both a real route **and** a cited
`file:line`, you do not report it.
---
## Use when
- The user wants to review authorization / access control or hunt for IDOR (broken object-level authorization)
- A Laravel CR run needs to verify that new or changed routes are scoped to their owner
- The user asks "which routes have no auth", "is this endpoint scoped to the owner", "do my controllers check policies", or "audit access control before launch"
- A PR introduces or changes routes, controllers, policies, gates, FormRequests, or API Resources
This skill complements `@skills/laravel-security/SKILL.md` (broad 7-area audit) and the
generic `@skills/security-review/SKILL.md` by going deep on the authorization chain with
a route-inventory anchor. Use it as the focused authorization lane inside a Laravel CR.
---
## Method
Work the steps in order. **Step 1 is non-negotiable: every finding traces to the real
route inventory and a cited controller line, never to a guess about what the code
"probably" does.**
### 1. Ground truth from `route:list`
1. **Confirm it's a Laravel project** — `artisan` at the repo root plus `app/` / `routes/`. If absent, stop and say so.
2. **Get the route inventory — the anchor:**
```bash
php artisan route:list --json
```
Returns, per route: HTTP `method`, `uri`, `name`, the full merged `middleware` list,
and `action`. This is the spine you reason about and the source of truth for *what
middleware actually applies* (route-group middleware is already merged in).
> `--json` renders middleware as **resolved class strings**, not aliases:
> `can:update,post` → `Illuminate\Auth\Middleware\Authorize:update,post`;
> `auth:sanctum` → `Illuminate\Auth\Middleware\Authenticate:sanctum`; a custom
> `role:admin` → its own class. **Never decide "no authz" by grepping for the literal
> `can:`** — match the `Authorize:` / class form too, and read any custom middleware
> class. See `references/auth-patterns.md` §2a.
3. **Scope the review** to **application routes** — skip framework / vendor / `telescope` / `horizon` / `_ignition` routes unless asked. For a **PR / diff**, intersect the route list with the changed files.
4. **Fallback when the app won't boot:** parse `routes/*.php` statically + Grep for `Route::` / middleware, **report it as not verified** (`Not verified: route middleware — php artisan route:list did not boot; routes read statically.`), and move every structural finding to the open-point class: the merged route-group middleware is exactly what the static read cannot see. Never silently substitute a guess for the real list.
> Laravel 11/12 boots `route:list` on defaults even with no `.env` — a missing `.env` is *not* a reliable failure trigger. Real boot failures: a missing PHP extension, or a service provider that hits the DB / cache at boot. If it's a missing extension you can enable, prefer fixing the boot (`php -d extension=gd artisan route:list --json`) over the lossy static fallback. Glob **all** of `routes/*.php` — apps split into `admin.php`, `api.base.php`, etc.
### 2. The authorization chain — four layers per route
On a large app, **establish the convention first, then audit by exception**: find how
the app authorizes (base controller `AuthorizesRequests`? policies auto-discovered or
registered? object scoping inline or in a repository / custom Builder? auth per-route or
per group?), then most routes are a fast conformance check and the findings are the
**deviations**.
For each in-scope route, record which layers are present:
| Layer | Present when (fact you can cite) | Where to look |
|-------|----------------------------------|---------------|
| **1 · Authentication** | `auth`, `auth:sanctum`, `auth:api`, or a custom guard in the route's `middleware` | `route:list` |
| **2 · Authorization** | `can:` middleware (renders as `Illuminate\Auth\Middleware\Authorize:<ability>`) **OR** `Gate::authorize`/`allows`/`denies` / `$this->authorize()` (needs `AuthorizesRequests`) / `$user->can()` **OR** `authorizeResource()` in the constructor **OR** a FormRequest whose `authorize()` returns a *real* check | `route:list` + controller / request |
| **3 · Object scoping (IDOR core)** | the record is fetched **scoped to the actor** (`$request->user()->posts()->findOrFail(...)`, scoped route binding) **OR** authorized against the bound model (`authorize('update', $post)`) | controller method |
| **4 · Policy exists** | an `App\Policies\{Model}Policy` is auto-discovered, bound via `#[UsePolicy]`, or registered with `Gate::policy(...)` (in `AppServiceProvider::boot()` on L11+, or `AuthServiceProvider::$policies` on ≤10) | filesystem + provider |
> **Laravel-version note.** Since Laravel 11 the base `Controller` is empty and does
> **not** include `AuthorizesRequests`, so `$this->authorize()` / `authorizeResource()`
> exist only when the controller adds `use AuthorizesRequests;`; the always-available
> form is `Gate::authorize(...)`. `AuthServiceProvider` is gone from the skeleton —
> policies are registered in `AppServiceProvider::boot()`, via `#[UsePolicy]`, or
> auto-discovered. Recognize **all** of these. See `references/auth-patterns.md`.
### 3. The IDOR core — object-level authorization (the flagship)
- **Implicit route-model binding resolves, it does NOT authorize.** `public function update(Post $post)` loads *anyone's* post by id. Without `authorize('update', $post)` or owner-scoping, that's IDOR — the single most common Laravel access-control bug.
- **`Model::find($request->id)` / `findOrFail($id)`** then mutate / return, with no policy call and no `where('user_id', ...)` / relationship scoping → finding, severity by what the model's owner key and relationships verify.
- **Actor-selected target from request input (IDOR on write — the worst case).** When the record to *write* is chosen by an id from the **request body / query** (`User::findOrFail($request->input('user_id'))->update(...)`) with no ownership check, that's a **confirmed cross-account write: structural evidence even without route-model binding** — the attacker names the victim directly. Do **not** soften it to "verify".
- **Scoped bindings count:** `Route::scopeBindings()` or a nested resource scoping the child to the parent (`users.posts` resolving `$post` through `$user->posts()`) *is* object scoping — credit it.
- **Mass-assignment adjacency:** `$model->update($request->all())` where `$fillable` includes an ownership / role key (`user_id`, `team_id`, `is_admin`) is privilege escalation even when the row was scoped — note as a related finding.
To judge "should this be scoped?", look for ownership signals: `user_id` / `team_id` /
`tenant_id` columns, `belongsTo(User::class)`, a global scope, or the user's relationship
methods. **If you can't establish the model is owned, the finding is Moderate and
phrased as "verify", not asserted as a hole.**
> **Tenancy comes from the project manifest.** Read `tenancy` through
> `skills/_shared/read-manifest.sh` (`@rules/general/general.md` *Project manifest*).
> When it is `database`, each tenant owns its own database, and the tenant connection is
> the tenant boundary. A model on the tenant connection is scoped by that connection, so a
> missing tenant `where` (`tenant_id`, `account_id`) on it is **not** IDOR — do not report it.
> A model on a shared connection still needs tenant scoping, and a missing tenant `where`
> there stays a finding. Owner scoping inside one tenant (one user reading another user's
> record) does not change: judge it by the ownership signals above. When `tenancy` is
> absent, tables are shared, and tenant scoping is always required.
> Ownership can be **conditional on edition / config / feature flag** — the same model
> shared-by-design in one mode and user-owned in another. Say which mode the finding
> applies to ("cross-user read **only under the Plus license**"), don't assert or dismiss flatly.
### 4. API Resource output — the second leak surface
An endpoint can authorize the *action* correctly and still **leak data in the response**.
For routes returning a `JsonResource` / `ResourceCollection` (or a raw model / `->toArray()`):
- **Field-level:** flag resources exposing ownership / internal fields — `user_id`, `email`, `*_token`, `is_admin`, password hashes, other users' PII — without a per-field guard (`when()`, `mergeWhen()`, conditional on `$request->user()`).
- **Row-level:** flag a collection endpoint returning records not scoped to the actor (`PostResource::collection(Post::all())`) — IDOR at the list level.
This is an open-point finding: the exposed field is verified, and its sensitivity is a business decision the code does not settle. Cite the resource
`file:line` and the exact field, and put the sensitivity under *Not verified*.
### 5. False-positive control (first-class, not a footnote)
A security tool dies from false positives. Before flagging, apply these filters — and
when the code does not settle a point, **report the verified facts with the point under *Not verified*; don't drop or inflate the finding**:
- **Public-by-design routes** — login, register, `password/*`, email verification, landing / public listings, and signature-verified **webhooks** are *meant* to be unauthenticated. See `references/public-by-design.md`. If a route only *looks* public, mark it **"Not verified: public by design — the owner confirms"**.
- **Authorization can live in layers you must actually check** — a missing `$this->authorize()` is *not* a finding if a `can:` middleware, `authorizeResource()`, or a FormRequest already covers it. Verify all four layers first.
- **`FormRequest::authorize()` returning `true`** is only a finding when the route is otherwise unprotected *and* touches owned data; on a public / separately-authorized route it's intentional ("verify intent").
- **Admin / global contexts** — a route behind an `admin` / role middleware legitimately queries across all users; don't flag those unscoped queries as IDOR.
### 6. Classify each finding (evidence + CR severity)
One row per finding, most severe wins. The evidence class is mandatory and travels with an
evidence chain. Map onto the CR scale per `@rules/code-review/general.md`:
| Signal | Evidence | CR severity |
|--------|-----------|-------------|
| State-changing or owned-data route with **no auth AND no authz AND no scoping** | Structural | **Critical** |
| `FormRequest::authorize(){ return true; }` on an otherwise-unprotected owned-data route | Structural | **Critical** |
| Cross-account write — record to mutate selected from request **input**, no ownership check, on an authenticated route | Structural | **Critical** |
| Route-model binding / `find($id)` with no `authorize()` and no owner scoping, on a model that carries an owner or tenant key, or whose ownership the code does not settle | Open point | **Moderate** |
| `Model::all()` / unscoped query, or an API Resource exposing owned / internal fields | Open point / Hardening | **Moderate** |
| No policy for a managed model, ad-hoc inline check instead of a policy, mass-assignment of ownership keys | Hardening — defense-in-depth | **Moderate** |
| All applicable layers present | — | Covered ✅ |
> **Evidence rule.** *Structural* = you can point at the missing layer structurally (route +
> method, no check anywhere in the chain). *Open point* = the missing check is verified, and
> whether it exposes data depends on a fact the code does not settle — whether the model is
> meant to be shared, whether the field is sensitive. Read the model and its relationships
> first; what still stays open goes under *Not verified* with the person or step that settles
> it. *Hardening* = stylistic / defense-in-depth. Never present an open-point or hardening
> finding as a confirmed vulnerability.
>
> **Structurally broken but currently fails-closed.** Some bugs deny by default *today*
> yet hide a latent leak (e.g. `authorizeResource` mapping `index → viewAny` when the
> policy has no `viewAny`, so the endpoint 403s, while the query is `Model::all()`). Lane
> the **active** defect by its structural severity and **separately note** the latent
> unscoped query — "fails closed now, leaks the moment a permissive `viewAny` / `before` is added."
### 7. Output — coverage map + prioritized lanes
Produce the report using `templates/report.md` as the template:
1. **Summary** — counts per severity + the 1–2 things to fix today.
2. **Coverage map** — a table of **every in-scope route** → `auth ✓ · authz ✓ · scoped ✓ · policy ✓` (✓ / ✗ / n/a). Never silently omit a route you reviewed.
> The `policy` column is **defense-in-depth**. When `authz` and `scoped` are satisfied *inline*, `policy ✗` is not an exposure, but it is still a **blocking Moderate** (extract a policy) — never Critical, and as a security-lens finding never deferred or excluded.
3. **Findings by lane**, each row carrying its evidence chain (`route → Controller@method:line → missing layer` + snippet) and evidence class: **Critical** (verify & fix now) → **Moderate** (open point or hardening, with the open point under *Not verified*) → **Covered** (summarized from the map).
4. For each Critical / Moderate: a **fix sketch** in Laravel idiom (add `authorize()`, scope the query through the relationship, write the policy) — as *advice for the human to apply*, not an edit.
List each route once, in its most-severe lane; the coverage map carries the rest.
### Assignment-declared "test-only" carve-out (issue #17)
Findings from this skill are **never** eligible for the Assignment-Declared Test-Only Conditions — Exclusion Gate (`@rules/code-review/general.md` *Assignment-Declared Test-Only Conditions — Exclusion Gate (issue #17)*), at **any** severity (Critical/Moderate). A "test-only" declaration on an assignment source may at most annotate a finding here as *"author claims test-only"* — it never removes the finding, never excludes it into `## Excluded per assignment`, and never drops it below the merge gate.
### 8. Saving the report (optional, on request only)
By default **output to the conversation only**. You may offer to save to
`storage/logs/authorization-review-<YYYY-MM-DD>.md` (Laravel-native, git-ignored) — write
the file **only if the user agrees**. This report is the only file the skill may ever
write, and only when asked.
---
## Evidence & honesty
Separate the two in every report (`@rules/general/general.md` *Verified statements only*):
- **Hard facts** — the route inventory, its middleware, which files / methods / policies exist, the model's owner key and relationships. Cite them.
- **Open points** — whether a model is meant to be shared, whether a field is sensitive. Never assume an answer: put each one under *Not verified* with the person or step that settles it. The fix sketch is advice and is labelled as advice.
> A clean report means "no broken authorization found in the layers I checked" — **NOT**
> "this app is secure." This skill does not cover business-logic authorization, indirect
> references it can't see in code, or the boundaries below. Never imply a clean pass is a pentest.
## Boundaries
Reviews **HTTP authorization**: routes, controllers, policies, gates, FormRequests,
Eloquent query scoping, API Resource output. Does **NOT** cover (say so when relevant):
- **Livewire / Filament / Nova / Inertia action authorization** — callable outside `route:list`, own authorization models; out of scope for this version.
- **Business-logic authorization** (approval states, "can a manager refund after 30 days") — needs domain knowledge; flag for human / pentest.
- **IDOR via indirect references not visible in code** (predictable IDs leaked elsewhere, signed-URL misuse).
- **AuthN strength** (password policy, MFA, token lifetime) and infra / gateway authz.
## Anti-patterns
- ❌ **Invent a route, policy, controller, or method** — no anchor → no finding.
- ❌ **Treat a code comment as evidence** — cite the structural reality (the missing call, the unscoped query, the route + line), never what a comment claims.
- ❌ **Flag "missing authorize()" without checking all four layers** — a `can:` middleware, `authorizeResource()`, or FormRequest may already cover it.
- ❌ **Flag a public-by-design route** (login / register / webhook / landing) for missing auth — consult `references/public-by-design.md`.
- ❌ **Assert IDOR on a model you haven't shown is owned** — a Moderate open point with the ownership under *Not verified*, not a confirmed hole.
- ❌ **Flag unscoped queries inside an admin / role-gated context** as IDOR — that's expected.
- ❌ **Present an open-point or hardening finding as a confirmed vulnerability.**
- ❌ **Drop a reviewed route from the coverage map** — show every route you checked, including the covered ones.
- ❌ **Edit routes, controllers, policies, or any source**, or run state-changing commands.
---
## References
- `references/auth-patterns.md` — how Laravel authz appears across middleware, policies, gates, and `route:list --json`.
- `references/public-by-design.md` — routes that are meant to be unauthenticated.
- `templates/report.md` — the coverage-map report template.
- [Laravel Authorization docs](https://laravel.com/docs/authorization) · [OWASP API Security Top 10 — BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
## Done when
- The route inventory was obtained (or the static fallback was used and stated), and the review was scoped to application routes / the PR diff.
- Every finding is anchored to a real route **and** a cited `file:line`, classified by evidence, and mapped to a CR severity.
- A coverage map lists every in-scope route, including the covered ones.
- Critical / Moderate findings carry a Laravel-idiom fix sketch — advice only, no code edited.
@rules/php/core-standards.md@rules/laravel/laravel.md and @rules/laravel/architecture.md@rules/security/backend.md — Database (authentication & authorization, least privilege) and Safe Validation & Error Messages (a 403-vs-404 distinction that confirms a resource exists is itself an authorization-granularity leak)@rules/code-review/general.md — map every finding onto the CR severity scale (Critical / Moderate) so this skill plugs into a Laravel CR runphp artisan route:list --json). Never edits routes, controllers, policies, or any source; emits a report plus fix sketches for a human to apply.@rules/reports/general.md — output in English, or in the language the project manifest sets in language.github when the findings fold into the GitHub PR comment (Project override — language.github in the manifest).This skill runs in one of two modes, selected by the caller via MODE (default audit):
audit (default) — the full walk below: every application route in the inventory, the per-route coverage map, and the whole report template. Every section behaves as written unless it is explicitly flagged for MODE=cr.cr (read-only lens — invoked by @skills/code-review/SKILL.md, code-review-github, code-review-jira, and code-review-bugsnag when the diff touches an authorization surface) — never modify a route, a controller, a policy, or any other file, never author a test, never stage / commit / push, never run a fixer or a checker, and never chain a follow-up review. The one command the lens runs stays the read-only php artisan route:list --json of step 1. Scope the walk to the routes the PR diff touches — step 1.3's intersection with the changed files is mandatory here, not optional — and return the findings as markdown carrying the reproducer fields the CR folds into its standard Critical / Moderate buckets. Emit every fix as a written snippet, never applied.What this lens owns in a CR: object-level authorization — whether the record a changed route reads, returns, or writes is scoped to the actor who asked for it (IDOR / BOLA), and whether that route's middleware → policy / gate → scoping → Resource-output chain authorizes at all. It reports on the routes the diff touched and never on a pre-existing route it did not. What it does not render on a CR: the per-route coverage map and the standalone report header of
templates/report.md. A CR carries findings, and the coverage map is an audit artifact. Evidence still governs severity. A route whose exposure rests on a point the code does not settle is reported at the severity its verified facts support and is never promoted to close the gap — Evidence & honesty below is unchanged by this mode. Whenroute:listwill not boot, the lens runs on the static fallback of step 1.4, reports it as not verified, and moves every structural finding to the open-point class.
SAST tells you where data flows. This tells you where it flows to the wrong user.
This skill is the judgment layer for the one category automated scanners structurally
cannot do: broken object-level authorization (IDOR / BOLA) — #1 in the OWASP API
Security Top 10. Taint scanners trace untrusted input; they cannot decide whether
Order::find($id) should have been scoped to the current user. That is a question
about intent, reasoned across middleware, controller, policy, and query.
The skill is trustworthy because every finding traces to a ground-truth anchor:
php artisan route:list --json is the deterministic inventory of every endpoint and
its merged middleware. If you cannot point to both a real route and a cited
file:line, you do not report it.
This skill complements @skills/laravel-security/SKILL.md (broad 7-area audit) and the
generic @skills/security-review/SKILL.md by going deep on the authorization chain with
a route-inventory anchor. Use it as the focused authorization lane inside a Laravel CR.
Work the steps in order. Step 1 is non-negotiable: every finding traces to the real route inventory and a cited controller line, never to a guess about what the code "probably" does.
route:listartisan at the repo root plus app/ / routes/. If absent, stop and say so.php artisan route:list --json
Returns, per route: HTTP method, uri, name, the full merged middleware list,
and action. This is the spine you reason about and the source of truth for what
middleware actually applies (route-group middleware is already merged in).
--jsonrenders middleware as resolved class strings, not aliases:can:update,post→Illuminate\Auth\Middleware\Authorize:update,post;auth:sanctum→Illuminate\Auth\Middleware\Authenticate:sanctum; a customrole:admin→ its own class. Never decide "no authz" by grepping for the literalcan:— match theAuthorize:/ class form too, and read any custom middleware class. Seereferences/auth-patterns.md§2a.
telescope / horizon / _ignition routes unless asked. For a PR / diff, intersect the route list with the changed files.routes/*.php statically + Grep for Route:: / middleware, report it as not verified (Not verified: route middleware — php artisan route:list did not boot; routes read statically.), and move every structural finding to the open-point class: the merged route-group middleware is exactly what the static read cannot see. Never silently substitute a guess for the real list.
Laravel 11/12 boots
route:liston defaults even with no.env— a missing.envis not a reliable failure trigger. Real boot failures: a missing PHP extension, or a service provider that hits the DB / cache at boot. If it's a missing extension you can enable, prefer fixing the boot (php -d extension=gd artisan route:list --json) over the lossy static fallback. Glob all ofroutes/*.php— apps split intoadmin.php,api.base.php, etc.
On a large app, establish the convention first, then audit by exception: find how
the app authorizes (base controller AuthorizesRequests? policies auto-discovered or
registered? object scoping inline or in a repository / custom Builder? auth per-route or
per group?), then most routes are a fast conformance check and the findings are the
deviations.
For each in-scope route, record which layers are present:
| Layer | Present when (fact you can cite) | Where to look |
|---|---|---|
| 1 · Authentication | auth, auth:sanctum, auth:api, or a custom guard in the route's middleware |
route:list |
| 2 · Authorization | can: middleware (renders as Illuminate\Auth\Middleware\Authorize:<ability>) OR Gate::authorize/allows/denies / $this->authorize() (needs AuthorizesRequests) / $user->can() OR authorizeResource() in the constructor OR a FormRequest whose authorize() returns a real check |
route:list + controller / request |
| 3 · Object scoping (IDOR core) | the record is fetched scoped to the actor ($request->user()->posts()->findOrFail(...), scoped route binding) OR authorized against the bound model (authorize('update', $post)) |
controller method |
| 4 · Policy exists | an App\Policies\{Model}Policy is auto-discovered, bound via #[UsePolicy], or registered with Gate::policy(...) (in AppServiceProvider::boot() on L11+, or AuthServiceProvider::$policies on ≤10) |
filesystem + provider |
Laravel-version note. Since Laravel 11 the base
Controlleris empty and does not includeAuthorizesRequests, so$this->authorize()/authorizeResource()exist only when the controller addsuse AuthorizesRequests;; the always-available form isGate::authorize(...).AuthServiceProvideris gone from the skeleton — policies are registered inAppServiceProvider::boot(), via#[UsePolicy], or auto-discovered. Recognize all of these. Seereferences/auth-patterns.md.
public function update(Post $post) loads anyone's post by id. Without authorize('update', $post) or owner-scoping, that's IDOR — the single most common Laravel access-control bug.Model::find($request->id) / findOrFail($id) then mutate / return, with no policy call and no where('user_id', ...) / relationship scoping → finding, severity by what the model's owner key and relationships verify.User::findOrFail($request->input('user_id'))->update(...)) with no ownership check, that's a confirmed cross-account write: structural evidence even without route-model binding — the attacker names the victim directly. Do not soften it to "verify".Route::scopeBindings() or a nested resource scoping the child to the parent (users.posts resolving $post through $user->posts()) is object scoping — credit it.$model->update($request->all()) where $fillable includes an ownership / role key (user_id, team_id, is_admin) is privilege escalation even when the row was scoped — note as a related finding.To judge "should this be scoped?", look for ownership signals: user_id / team_id /
tenant_id columns, belongsTo(User::class), a global scope, or the user's relationship
methods. If you can't establish the model is owned, the finding is Moderate and
phrased as "verify", not asserted as a hole.
Tenancy comes from the project manifest. Read
tenancythroughskills/_shared/read-manifest.sh(@rules/general/general.mdProject manifest). When it isdatabase, each tenant owns its own database, and the tenant connection is the tenant boundary. A model on the tenant connection is scoped by that connection, so a missing tenantwhere(tenant_id,account_id) on it is not IDOR — do not report it. A model on a shared connection still needs tenant scoping, and a missing tenantwherethere stays a finding. Owner scoping inside one tenant (one user reading another user's record) does not change: judge it by the ownership signals above. Whentenancyis absent, tables are shared, and tenant scoping is always required.
Ownership can be conditional on edition / config / feature flag — the same model shared-by-design in one mode and user-owned in another. Say which mode the finding applies to ("cross-user read only under the Plus license"), don't assert or dismiss flatly.
An endpoint can authorize the action correctly and still leak data in the response.
For routes returning a JsonResource / ResourceCollection (or a raw model / ->toArray()):
user_id, email, *_token, is_admin, password hashes, other users' PII — without a per-field guard (when(), mergeWhen(), conditional on $request->user()).PostResource::collection(Post::all())) — IDOR at the list level.This is an open-point finding: the exposed field is verified, and its sensitivity is a business decision the code does not settle. Cite the resource
file:line and the exact field, and put the sensitivity under Not verified.
A security tool dies from false positives. Before flagging, apply these filters — and when the code does not settle a point, report the verified facts with the point under Not verified; don't drop or inflate the finding:
password/*, email verification, landing / public listings, and signature-verified webhooks are meant to be unauthenticated. See references/public-by-design.md. If a route only looks public, mark it "Not verified: public by design — the owner confirms".$this->authorize() is not a finding if a can: middleware, authorizeResource(), or a FormRequest already covers it. Verify all four layers first.FormRequest::authorize() returning true is only a finding when the route is otherwise unprotected and touches owned data; on a public / separately-authorized route it's intentional ("verify intent").admin / role middleware legitimately queries across all users; don't flag those unscoped queries as IDOR.One row per finding, most severe wins. The evidence class is mandatory and travels with an
evidence chain. Map onto the CR scale per @rules/code-review/general.md:
| Signal | Evidence | CR severity |
|---|---|---|
| State-changing or owned-data route with no auth AND no authz AND no scoping | Structural | Critical |
FormRequest::authorize(){ return true; } on an otherwise-unprotected owned-data route |
Structural | Critical |
| Cross-account write — record to mutate selected from request input, no ownership check, on an authenticated route | Structural | Critical |
Route-model binding / find($id) with no authorize() and no owner scoping, on a model that carries an owner or tenant key, or whose ownership the code does not settle |
Open point | Moderate |
Model::all() / unscoped query, or an API Resource exposing owned / internal fields |
Open point / Hardening | Moderate |
| No policy for a managed model, ad-hoc inline check instead of a policy, mass-assignment of ownership keys | Hardening — defense-in-depth | Moderate |
| All applicable layers present | — | Covered ✅ |
Evidence rule. Structural = you can point at the missing layer structurally (route + method, no check anywhere in the chain). Open point = the missing check is verified, and whether it exposes data depends on a fact the code does not settle — whether the model is meant to be shared, whether the field is sensitive. Read the model and its relationships first; what still stays open goes under Not verified with the person or step that settles it. Hardening = stylistic / defense-in-depth. Never present an open-point or hardening finding as a confirmed vulnerability.
Structurally broken but currently fails-closed. Some bugs deny by default today yet hide a latent leak (e.g.
authorizeResourcemappingindex → viewAnywhen the policy has noviewAny, so the endpoint 403s, while the query isModel::all()). Lane the active defect by its structural severity and separately note the latent unscoped query — "fails closed now, leaks the moment a permissiveviewAny/beforeis added."
Produce the report using templates/report.md as the template:
auth ✓ · authz ✓ · scoped ✓ · policy ✓ (✓ / ✗ / n/a). Never silently omit a route you reviewed.
The
policycolumn is defense-in-depth. Whenauthzandscopedare satisfied inline,policy ✗is not an exposure, but it is still a blocking Moderate (extract a policy) — never Critical, and as a security-lens finding never deferred or excluded.
route → Controller@method:line → missing layer + snippet) and evidence class: Critical (verify & fix now) → Moderate (open point or hardening, with the open point under Not verified) → Covered (summarized from the map).authorize(), scope the query through the relationship, write the policy) — as advice for the human to apply, not an edit.List each route once, in its most-severe lane; the coverage map carries the rest.
Findings from this skill are never eligible for the Assignment-Declared Test-Only Conditions — Exclusion Gate (@rules/code-review/general.md Assignment-Declared Test-Only Conditions — Exclusion Gate (issue #17)), at any severity (Critical/Moderate). A "test-only" declaration on an assignment source may at most annotate a finding here as "author claims test-only" — it never removes the finding, never excludes it into ## Excluded per assignment, and never drops it below the merge gate.
By default output to the conversation only. You may offer to save to
storage/logs/authorization-review-<YYYY-MM-DD>.md (Laravel-native, git-ignored) — write
the file only if the user agrees. This report is the only file the skill may ever
write, and only when asked.
Separate the two in every report (@rules/general/general.md Verified statements only):
A clean report means "no broken authorization found in the layers I checked" — NOT "this app is secure." This skill does not cover business-logic authorization, indirect references it can't see in code, or the boundaries below. Never imply a clean pass is a pentest.
Reviews HTTP authorization: routes, controllers, policies, gates, FormRequests, Eloquent query scoping, API Resource output. Does NOT cover (say so when relevant):
route:list, own authorization models; out of scope for this version.can: middleware, authorizeResource(), or FormRequest may already cover it.references/public-by-design.md.references/auth-patterns.md — how Laravel authz appears across middleware, policies, gates, and route:list --json.references/public-by-design.md — routes that are meant to be unauthenticated.templates/report.md — the coverage-map report template.file:line, classified by evidence, and mapped to a CR severity.How can I help you explore Laravel packages today?