# Authorization & Policies · Martis docs

> Martis authorizes through the standard Laravel policy system: every write-side endpoint consults a Laravel policy. The frontend receives the resolved booleans and hides or disables controls accordingly.

Source: https://getmartis.com/docs/auth/authorization

# Authorization & Policies

Martis authorizes through the standard Laravel policy system: every write-side endpoint consults a Laravel policy. The frontend receives the resolved booleans and hides or disables controls accordingly.

The backend remains the source of truth: every request is re-authorized server-side even if the UI was already hidden.

## At a glance

When **no policy** resolves for the resource, every row below permits. When a policy exists but does not define the method, the last column applies (`Resource::defaultForMissingAbility()` and the fallbacks):

| Concern | Method on `Resource` | Policy ability | Policy exists, method missing |
| --- | --- | --- | --- |
| List / index | `authorizedToViewAny` | `viewAny` | permit |
| Detail / show | `authorizedToView` | `view` | deny |
| Create form & store | `authorizedToCreate` | `create` | deny |
| Edit form & update | `authorizedToUpdate` | `update` | deny |
| Delete / soft-delete | `authorizedToDelete` | `delete` | deny |
| Restore | `authorizedToRestore` | `restore` | deny |
| Force delete | `authorizedToForceDelete` | `forceDelete` | deny |
| Replicate | `authorizedToReplicate` | `replicate` | falls back to `create` AND `update`; the prefill route (`GET /api/resources/{resource}/{id}/replicate`) also needs `view` of the record, as the detail page does |
| Run action | `authorizedToRunAction` | `runAction` | falls back to `update` |
| Run destructive action | `authorizedToRunDestructiveAction` | `runDestructiveAction` | falls back to `delete` |
| Attach related | `authorizedToAttach` | `attach{Model}` | permit |
| Detach related | `authorizedToDetach` | `detach{Model}` | permit |
| Attach any (parent check) | `authorizedToAttachAny` | `attachAny{Model}` | permit |
| Add related (HasMany inline create) | `authorizedToAdd` | `add{Model}` | permit |
| Update pivot row | `authorizedToUpdatePivot` | `updatePivot{Model}` | falls back to `update` |

Default behaviour:

- If no policy class is registered for the model, every ability is permitted. This keeps local development frictionless.
- Once a policy class exists, missing *methods* follow the defaults matrix (most deny; `viewAny` permits; relation abilities are permissive by design because attaching a model the user can already update is usually the right default).

### viewAny is the entry gate

`viewAny` is consulted **before the record query on every per-record endpoint** as well as on the collection ones, and on the create endpoints (`POST /api/resources/{resource}`, the inline create form and its store, v2.0; Nova 2 refused every resource route the same way, [nova-issues#1762](https://github.com/laravel/nova-issues/issues/1762), and no public source says whether Nova 4 or 5 still does): detail / show (including the `?context=update` form payload), update, destroy, restore, force-delete, replicate, peek, single and bulk actions, pivot actions, and every relationship endpoint that resolves a parent record (`has-many`, `has-one`, `belongs-to-many` and its attachable picker, `morph-*`). A user the policy does not allow to list a resource gets `403` from `GET /api/resources/{resource}/{id}` before `find()` runs, for an existing id and a missing one alike. Two consequences:

- Record ids cannot be probed (`404` vs `403`) through a resource the user is not allowed to see.
- A model scope that fails closed (throws when no tenant / owner is resolved) never turns a deep-link into a `500`: the request is refused at the collection gate, before the scope is evaluated.

The record-level abilities (`view`, `update`, `delete`, …) are still checked after the query, exactly as before. A policy with `viewAny` denied and `view` granted therefore never reaches a record any more; if you relied on that combination for deep-links, grant `viewAny` and confine the listing with `indexQuery()` instead.

A write through a relationship endpoint (`POST`, `PUT` and `DELETE` on `has-many`, `has-one`, `morph-many` and `morph-one`) creates, updates or deletes a record of the **related** resource, so it also needs that resource's `viewAny` (v2.0), checked before the related record query, then its `create` / `update` / `delete` ability as before. A user who cannot list `Comment` cannot write a comment through a post's panel either, as in Nova. `routable()` is not part of that check: a headless resource (v1.24.0, see [Resources → routable](https://getmartis.com/docs/core/resources)) stays usable as a relation target.

Reading through a relationship needs it too (v2.0.1): a relationship panel (`HasMany`, `HasOne`, `MorphMany`, `MorphOne`, `BelongsToMany`, `MorphToMany` and the fields built on them) is left off the detail page, and its routes answer `403`, for a user the related resource does not let `viewAny`, as in Nova. See [Relationships → Panels follow the related resource's `viewAny`](https://getmartis.com/docs/core/relationships#panels-follow-the-related-resources-viewany-v201).

## Panel access (viewMartis)

`viewMartis` decides who may open the panel at all (v2.1.0). Since v2.4.0 the panel is closed by default outside the local environments:

- **Defined:** only the users it allows get in, in every environment.
- **Undefined:** every user the Martis guard signs in gets in only in the environments of `martis.panel_access.open_environments` (`local` and `testing` by default, env `MARTIS_PANEL_OPEN_ENVIRONMENTS`, comma-separated, an empty list opens none), as Nova's `viewNova` does with `local`. Anywhere else the panel answers `403` to everyone until you define the gate. Up to v2.3.x an undefined gate was open in every environment, so a guard that also signs in customers or other non-staff users opened the admin to them by omission.

Define it in `app/Providers/MartisServiceProvider.php`. `martis:install` publishes the provider with the gate active (it lets `local` and `testing` in (so a test suite run as `APP_ENV=testing` keeps working) and, anywhere else, only the addresses in its `$panelEmails` property), and prints a notice about it:

```
use Illuminate\Support\Facades\Gate;
 
class MartisServiceProvider extends ServiceProvider
{
    /**
     * @var list<string>
     */
    protected array $panelEmails = [
        'admin@example.com',
    ];
 
    protected function registerGates(): void
    {
        Gate::define('viewMartis', fn ($user) => app()->environment(['local', 'testing']) || in_array($user->email, $this->panelEmails, true));
    }
}
```

Edit the list (or replace the rule with a role, a column or a permission) before you deploy. The list is a typed property, not an inline array, so the provider passes PHPStan level 8 while it is still empty (v2.10.1): against an empty literal list PHPStan reports that `in_array()` will always be false. The first time the panel refuses a user because the gate is missing, Martis logs one warning a day (not one per request) that names the fix.

### Resources without a policy

A resource with no policy still lets every user the panel admits view, create, update and delete its records, as in Nova. Outside the environments the panel is open in, Martis logs a warning once a day for each resource found without a policy (`Martis\Auth\PolicyCoverage`), so the gap shows up in the logs without a line per request. Add a policy (`php artisan martis:policy`) to close it.

The `martis.authorize` middleware checks it after authentication, the 2FA challenge and email verification, and before the forced password change (v2.3.0). It therefore guards the SPA shell, every protected API route, every Tool route that runs the Martis API stack and any route of yours on the `martis.api` group. The sign-in pages, `POST /logout`, the 2FA challenge (its page and its endpoint), the email verification pages and the translations stay reachable, so a refused user can sign out, and a refused user with a pending 2FA challenge completes it before seeing the refusal.

| Request of a refused user | Answer |
| --- | --- |
| JSON (the SPA's API calls, a Tool route) | `403 {"message": "You do not have access to this panel."}`, translated |
| A page | `403` with a standalone "No access to this panel" screen and a **Sign out** button |

`Gate::before()` callbacks apply, so a super-admin rule lets its users in. The user is not passed to the gate as an argument, so a policy of your user model is never consulted for it. An operator cannot impersonate a user the gate refuses: `POST /api/impersonation/start/{id}` answers `422`. The gate evaluates the impersonated user during the session, so if it stops accepting that user mid-session, `POST /api/impersonation/stop` stays reachable and the no-access screen offers **Stop impersonating** instead of **Sign out**: the session goes back to the operator. Every other route stays refused.

## Writing a policy

Martis looks for policies in two places, in order:

1. The class declared on the resource via `public static ?string $policy = \App\Policies\PostPolicy::class;`.
2. Laravel's auto-resolved policy for the model (`AuthServiceProvider::$policies` or Laravel's auto-discovery).

Example:

```
<?php
 
namespace App\Policies;
 
use App\Models\Post;
use App\Models\User;
 
class PostPolicy
{
    public function viewAny(User $user): bool
    {
        return true;
    }
 
    public function view(User $user, Post $post): bool
    {
        return $user->id === $post->author_id || $user->is_admin;
    }
 
    public function create(User $user): bool
    {
        return $user->hasVerifiedEmail();
    }
 
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->author_id;
    }
 
    public function delete(User $user, Post $post): bool
    {
        return $user->is_admin;
    }
 
    // Optional — action authorization
    public function runAction(User $user, Post $post): bool
    {
        return $this->update($user, $post);
    }
 
    public function runDestructiveAction(User $user, Post $post): bool
    {
        return $this->delete($user, $post);
    }
 
    // Optional — pivot table writes on a belongsToMany / morphToMany
    public function updatePivotTag(User $user, Post $post, \App\Models\Tag $tag): bool
    {
        return $this->update($user, $post);
    }
 
    // Optional: whether the user may attach any tag to this post at all
    public function attachAnyTag(User $user, Post $post): bool
    {
        return $this->update($user, $post);
    }
 
    // Optional — attach / detach on belongsToMany / morphToMany
    public function attachTag(User $user, Post $post, \App\Models\Tag $tag): bool
    {
        return $this->update($user, $post);
    }
 
    public function detachTag(User $user, Post $post, \App\Models\Tag $tag): bool
    {
        return $this->update($user, $post);
    }
}
```

### Policy responses

A policy method, and its `before()`, may return Laravel's [`Illuminate\Auth\Access\Response`](https://laravel.com/docs/authorization#policy-responses) instead of a boolean, as with the Gate: `Response::allow()` permits, `Response::deny()`, `Response::denyAsNotFound()` and `Response::denyWithStatus()` deny. Any other value permits when it is truthy.

```
use Illuminate\Auth\Access\Response;
 
public function view(User $user, Post $post): Response
{
    return $post->team_id === $user->team_id
        ? Response::allow()
        : Response::denyAsNotFound();
}
```

**Security fix (v2.11.0 and v1.39.8).** Martis calls the policy itself (the Gate is not involved, see [How policy instances are resolved](https://getmartis.com/docs/auth/authorization#how-policy-instances-are-resolved)) and used to cast the answer to a boolean. An object is always `true`, so a policy that returned `Response::deny()` (or `denyAsNotFound()`) **allowed** the request on every Resource ability: a `PUT` saved the record. Policies that return booleans were never affected.

## HTTP responses

Every Martis controller returns **HTTP 403** with a JSON body of `{ "message": "This action is unauthorized.", "errors": [] }` when the policy denies. The frontend translates the generic 403 into a **localised "Not authorized"** toast via `error_forbidden` in the `messages.php` language files.

A denial that carries a status changes that answer on the endpoints that read or write **one record** (v2.11.0), when the record's `view`, `update`, `delete`, `restore`, `forceDelete` or `replicate` ability refuses it: show (and its `?context=update` form payload), update, destroy, restore, force-delete, the replicate prefill, peek, the update form's per-field endpoints, every relationship route that reads its parent record (`has-many`, `has-one`, `belongs-to-many`, `morph-*`, the pivot actions, an action run from a relationship panel) and the related-record update and delete of `has-many`, `has-one`, `morph-many` and `morph-one`.

- `Response::denyAsNotFound()` answers `404` with exactly the body the route sends for a record that does not exist, so the ids a policy denies cannot be told apart from missing ones. This is the way to keep a narrower resource (for example a [headless](https://getmartis.com/docs/core/resources#routable) relation target over a model that has a page of its own) from confirming which records exist.
- `Response::denyWithStatus($status)` answers that status (a status outside 400 to 599 answers `403`), with the `This action is unauthorized.` body. The panel reads a `401` as an ended session and sends the user to the login page, and `419` is Laravel's expired-session status, so a policy should not use either for a record denial.
- `false` and `Response::deny()` keep answering `403`.

The collection-level checks (`viewAny`, `create`) and the relationship abilities (`attach{Model}`, `detach{Model}`, ...) keep answering `403` whatever the denial carries. One exception: when the policy has no `replicate` method, the replicate prefill needs `create` AND `update`, and answers the status carried by the one that refuses it (for example `404` from an `update` that returns `Response::denyAsNotFound()`).

Use `ApiError#isForbidden()` in custom frontend code to branch on authorization failures specifically.

## Visibility of non-record primitives

Every dashboard primitive supports a `canSee(Closure)` callback.

| Class | canSee? | Exposed to UI |
| --- | --- | --- |
| `Martis\Filters\Filter` | ✓ | Stripped from schema when false |
| `Martis\Metrics\Metric` | ✓ | Stripped from dashboard payload when false |
| `Martis\Dashboards\Dashboard` | ✓ | Stripped from navigation when false |
| `Martis\Cards\Card` | ✓ | Stripped from dashboard payload when false |
| `Martis\Lenses\Lens` | ✓ | Stripped from schema when false |

## Fields and relations

- `Field::canSee(Closure)`: hides a field from every context. Inside a `Repeater` row too (v1.38.0+): the field is left out of the row type's schema and of every row's values, is not validated, and a row never takes its value from the request (a stored row keeps it, a new row stores the field's `default()`). See [Repeater → Readonly, computed, hidden and immutable row fields](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields). On a pivot field of a `BelongsToMany` / `MorphToMany` too (v1.38.0+): it is left out of the relationship's schema and of the pivot values sent back, is not validated, and is never written from the request (the attach stores its `default()`). See [Relationships → With Pivot Fields](https://getmartis.com/docs/core/relationships#with-pivot-fields). A field placed directly in a `Tab` is hidden like one anywhere else (v1.38.0+; before, a `Tab` ignored `canSee()` on the fields it holds directly: they were listed, sent, validated and written). Among an Action's fields too (v1.38.0+): the modal does not list it, the run does not validate it, and `handle()` receives its `default()` or nothing, whatever the request sends. See [Actions → Fields the request cannot set](https://getmartis.com/docs/core/actions#fields-the-request-cannot-set). The endpoints a form field asks on its own (the options of a relation picker, the option search of a `Select`, the Slug check, the `dependsOn` sync) answer for a hidden field exactly as for an undeclared one (v1.38.0+).
- `Field::canSeeForModel(Closure)` / `canSeeUsingPolicy(ability)`: hides a field on the records the callback denies, on every read and every write of those records (v1.38.0+ for the writes). See [Per-field authorization](https://getmartis.com/docs/auth/authorization#per-field-authorization).
- `Field::readonly(bool|Closure)`: renders the field without an editor, and the save never takes its value from the request, inside a `Repeater` row included (v1.38.0+).
- Relation fields (`BelongsTo`, `HasMany`, `BelongsToMany`, `MorphTo`, `MorphToMany`, `MorphMany`, `MorphOne`, `HasOne`) emit `authorizedToCreate` / `authorizedToViewAny` flags **derived from the target resource's policy**. The inline "Create Related" button is automatically hidden when the current user cannot create the target resource, independent of the `showCreateRelationButton()` toggle. A `MorphTo` flags each of its types, and its button follows the type the operator picked (v1.38.0+; before, it showed for every type as soon as one was creatable). `Tag` applies the same rule to its inline create (v1.38.0+).
- `BelongsToMany` / `MorphToMany` attach: `attachAny{Model}` (`authorizedToAttachAny()`) gates the attach as a whole. When it denies, the list of records to attach (`.../attachable`), the attach itself (one record or several) and the pickers of the attach modal's pivot fields answer 403 (v1.38.0+). When it allows, `attach{Model}` decides per record (a batch attach skips the records it denies), asked only about a record the attach picker lists: any other id, a missing one included, answers 422 first (v2.11.1), `detach{Model}` decides the detach, and `updatePivot{Model}` (falling back to `update`) the pivot update and the pickers of the pivot edit modal. The field's `canAttach(false)` turns all of those off too, and `canDetach(false)` the detach (403, v2.4.0): the toggles narrow the policies, never widen them. The detach and the pivot update only reach those abilities for a record attached to the parent: one that is not attached answers 404, exactly like an id that does not exist (v2.11.1), so a related resource that confines its records only in `relatableQuery()` does not need a `detach{Model}` to hide another tenant's ids. See [Relationships → Only an attached record is detached](https://getmartis.com/docs/core/relationships#only-an-attached-record-is-detached). Before v1.38.0 the attach and the list of records to attach did not check `attachAny{Model}`, so a user it denied could still attach.

## UI flag contract

Each record serialized for the index or detail pages carries:

```
{
  "_authorization": {
    "authorizedToView": true,
    "authorizedToUpdate": false,
    "authorizedToDelete": false,
    "authorizedToReplicate": false,
    "authorizedToRunAction": false,
    "authorizedToRunDestructiveAction": false,
    "authorizedToRestore": false,      // only on soft-deletable resources
    "authorizedToForceDelete": false   // only on soft-deletable resources
  },
  "_actionAuthorization": {
    "publish": true,
    "archive": false
  }
}
```

`_actionAuthorization` says, per action, whether it may run on that record: the action's `canRun()`, then, unless it is `standalone()`, the `runAction` policy (`runDestructiveAction` for a destructive action), the same check the run applies. An index row maps every action the user can see; a relationship panel row maps only the inline actions, and carries no map when there is none. The policy is asked once per row, not per action: the map reuses the row's `authorizedToRunAction` / `authorizedToRunDestructiveAction` flags.

Top-level schema responses carry collection-level flags under `authorization`:

```
{
  "authorization": {
    "authorizedToViewAny": true,
    "authorizedToCreate": false
  }
}
```

The UI uses these booleans to disable / hide Create, Edit, Delete, Restore, Force Delete, Replicate, bulk and inline action buttons. When a control must be present but non-interactive (e.g. a bulk action in a list where only some rows allow it), the button is rendered `disabled`.

## Design notes

- **Action authorization is closure-first, policy-second.** Martis uses `Action::canSee()` / `Action::canRun()` closures because they compose better with runtime context. If you prefer a policy lookup, implement `runAction` / `runDestructiveAction` on your policy — Martis consults it as a fallback.
- **`_authorization` key prefix.** Authorization flags are namespaced under `_authorization` on each record to avoid collisions with user model attributes.
- **`updatePivot{Model}` ability** is a Martis convention for pivot row edits. It falls back to `update` on the parent so existing policies keep working.

## Per-field authorization

Most field visibility is per-request — `Field::canSee(fn (Request $r) => …)` is enough. When the decision needs to consult the row being rendered (e.g. hiding `email` for non-admins on a User index), use the v1.8.8 model-aware variant:

```
Email::make('email')
    ->canSeeForModel(fn (Request $r, User $user) => $r->user()?->can('viewEmail', $user) ?? false);
```

Sugar over a Gate ability:

```
Email::make('email')->canSeeUsingPolicy('viewEmail');
// Equivalent to canSeeForModel(fn (Request, Model) => Gate::forUser($request->user())->allows('viewEmail', $model))
```

A field the callback hides for a record is hidden for that record on the server, like a field `canSee()` hides for the request:

- **Every read** of the record leaves its value out: the index, the detail, the update form's values, the replicate form, the rows and the record of a relationship panel (with the responses of its inline create and update), the rows of a lens and the peek card. The bytes are not in the response, so even a tampered React layer cannot read the masked field.
- **Every write** of the record neither validates the field nor takes its value from the request: the request is accepted and the column keeps its value. This covers the resource update and create, the inline create, the inline forms of the `HasMany` / `HasOne` / `MorphMany` / `MorphOne` panels, and the attach and pivot update of the pivot fields of a `BelongsToMany` / `MorphToMany` (the attach stores the field's `default()`), as Nova leaves a field out of the update request of the resource instance it cannot see.
- **A create** decides on the new, unsaved model before any value of the request is written to it, as Nova resolves the fields of a create on a fresh model: a callback that grants on stored values (an owner column, `$model->exists`, a policy that reads them, as `viewEmail` above may for a user not created yet) denies, and the create does not write the field. Grant on `! $model->exists` when a create should write it.
- **A pivot field** decides on the pivot row (the relationship's pivot class; `pivotParent` is the parent record): the attached row when the pivot values are read or updated, a new row on attach.
- **A relationship field** (`HasMany`, `HasOne`, `MorphMany`, `MorphOne`, `BelongsToMany`, `MorphToMany`) the callback hides for the parent record answers 404 on every endpoint of its panel, as an undeclared relationship.
- **The endpoints a form field asks on its own** (relation picker options, `Select` search, Slug check, `dependsOn` sync) answer for a field hidden for the record the update form edits, or for the new model of a create form, exactly as for an undeclared field.

The schema describes the resource, not a record, so a form still lists a field the callback hides for the record it shows: its value is empty, and the save ignores it. See [Fields → Field authorization](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel).

Before v1.38.0 the callback only ran when the resource's own endpoints serialised a record (index, detail, update form values, replicate): every write validated the field and wrote the value the request sent, so a user who could not see a record's `email` could still set it with a `PUT` (and the update form, which sends every field it lists, emptied it); the relationship panels, the lens rows and the peek card sent the value; a relationship field it hid still served its panel; and a pivot field's callback was never asked.

## Declarative query scopes

`Resource::indexQuery()` is the imperative hook for one-off mutations. For invariants that should hold wherever the resource is listed as its index lists it (multi-tenancy, "archived = false", "subscription_active = true"), declare them with the v1.8.8 `scopes()` method:

```
public static function scopes(Request $request): array
{
    return [
        'tenant'  => fn (Builder $q) => $q->where('tenant_id', $request->user()->tenant_id),
        'visible' => fn (Builder $q) => $q->where('archived', false),
    ];
}
```

The labels are informational (used by future debug overlays). The order is iteration order — the array key declares a stable apply order across reloads. The controller calls `applyScopes()` BEFORE `indexQuery()` so the manual hook can override scope-applied predicates when really needed. Both surfaces feed the same Builder.

Both also apply, in that order, to every relationship panel that lists the resource (`HasMany`, `HasManyThrough`, `MorphMany`, `BelongsToMany`, `MorphToMany`, v2.0) and to the records an action run resolves, so a tenant scope declared here hides the other tenants' rows on another resource's detail page too. Before v2.0 a panel listed them. See [Resources → indexQuery()](https://getmartis.com/docs/core/resources#indexquery).

The count badge on the sidebar uses the same code path, so the scoped count always agrees with the row count on the index page.

Wherever Martis runs `indexQuery()`, it runs `scopes()` first (v2.0):

- **The global search** (`/api/search`, the Cmd+K palette): its results and the `total` of each group, on the database and the Scout paths. See [Global Search → Which records are searched](https://getmartis.com/docs/core/global-search#which-records-are-searched).
- **The records an action runs on** (see [Actions](https://getmartis.com/docs/core/actions)).
- **The parent record of a `BelongsToMany` panel** (its list, attachable list, attach, detach and pivot update) **and of the pivot routes** (the pivot actions, their fields and pickers, and the pickers of the pivot fields, on `belongs-to-many` and `morph-to-many`). A parent the scopes hide answers `404`, exactly like a missing one.

Before v2.0 the global search and those parent lookups ran `indexQuery()` alone, so a tenant confined with `scopes()` still found another tenant's records in the palette (title, subtitle, link and count) and reached the pivot panels of their records.

Neither hook applies to the relationship pickers (BelongsTo dropdowns, attach pickers): they list through [`relatableQuery()`](https://getmartis.com/docs/core/resources#relatablequery), as Nova's pickers do ([Nova → Relatable Filtering](https://nova.laravel.com/docs/v5/resources/authorization#relatable-filtering)), so declare the tenant predicate there too. The detail, update and delete endpoints rely on the policies, and so does the parent record of the `HasMany`, `HasOne`, `MorphMany` and `MorphOne` panels and of the `MorphToMany` panel's own endpoints (see [Relationships → How a panel finds its parent record](https://getmartis.com/docs/core/relationships#how-a-panel-finds-its-parent-record)). A lens starts from the index query, so both hooks apply to it too (v2.4.0), unless the lens opts out ([Lenses](https://getmartis.com/docs/core/lenses#the-lens-lists-what-the-index-lists)).

Wherever they run (the index page and its count badge since v2.0.1, and the three surfaces above since v2.0) the hooks run as Eloquent runs a local scope: what they add is wrapped in one group when it contains an `orWhere()`, so the filters, the search term, the selected ids or the key added after it bind to all of it. After `where('tenant_id', 1)->orWhere('shared', true)` the parent lookup reads `(tenant_id = 1 or shared) and id = ?`; ungrouped, it would read `tenant_id = 1 or (shared and id = ?)` and find another record of the tenant, an action would run on every record of the tenant, and the index would list every record of the tenant whatever the filters and the search said. A hook that starts with `orWhere()` reads as `and`, as it does on a query with no clause before it, so `?trashed=only` still lists trashed records only. Each index filter's `apply()` and the resource's `searchQuery()` run grouped too (v2.0.1): an `orWhere()` in them cannot OR the hooks away. Hooks that add only `and` clauses produce the same SQL as before.

## Audit log of denied authorizations

Off by default. Flip `MARTIS_AUDIT_AUTHZ_DENIALS=true` to record every Gate denial for an authenticated user into the `martis_action_events` audit table. The recorder listens to Laravel's `GateEvaluated` event, so it sees checks that go through the Gate (Tools, Dashboards, your own `$user->can()` calls) but **not** Resource checks, which call the policy methods directly (see [How policy instances are resolved](https://getmartis.com/docs/auth/authorization#how-policy-instances-are-resolved)). Each row carries:

- `name = authz.denied`
- `user_id` — the user the check ran for.
- `fields.ability` — the ability name.
- `fields.model_class` / `fields.model_id` — the target row when the gate received a Model argument.
- `status = denied`.

Repeat denials of the same `(user, ability, model)` within one request are de-duplicated to a single row, so a page that runs many redundant checks does not flood the table.

The log's `user_id` names a user of the Martis guard (the `ActionEvent::user()` relation resolves that guard's model), so a denial is recorded only while the Martis guard is the request's guard: in a panel request, and in every request when the Martis guard is the app's default. With a custom `MARTIS_GUARD`, the site's own requests record none (v2.0.0+): their user belongs to another guard, whose id the log would resolve to someone else.

The noisy `viewAny` cascade (sidebar / navigation) is dropped by default, and so is the `view-martis-action-events` gate the Action Events entry asks on every navigation build (v2.0.1+). Toggle `MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY=true` to keep both.

## How policy instances are resolved

Every check on a resource (`authorizedToView()`, the `_authorization` block, action visibility, relationship abilities) and on a Tool or Dashboard (`authorizedToSee()`) goes through `resolvePolicy()`:

1. `public static ?string $policy` on the class.
2. Convention: `{martis.policy_namespace}\{BaseName}Policy` (`UserResource` → `UserPolicy`, `ProLabDashboard` → `ProLabPolicy`).
3. The policy registered in Laravel's Gate (`Gate::policy(...)`, or the one Laravel guesses): for the model class on Resources, for the entity class itself on Tools and Dashboards.
4. Nothing: no policy, the [defaults](https://getmartis.com/docs/auth/authorization#at-a-glance) apply.

Resources call the policy methods directly (honouring the policy's own `before()`, and reading a returned `Response` through `allowed()`, see [Policy responses](https://getmartis.com/docs/auth/authorization#policy-responses)); Tools and Dashboards ask Laravel's Gate (`Gate::allows('view', [Entity::class])`), so `Gate::before()` / `after()` and `GateEvaluated` listeners (the denial audit, the per-request cache) apply to them. Since v1.36.0 Martis registers the resolved policy for the entity class with the Gate on the first check, so declaring `$policy` is enough; earlier versions hid the entity unless the host also called `Gate::policy(Entity::class, Policy::class)` by hand.

Since v1.36.0 the **outcome of that walk** (the policy class) is memoised per entity class in `Martis\Authorization\PolicyResolver`, a container-scoped service: the memo lives for one request under Octane, one job under `queue:work` and one application instance in a test suite, and dies with it. The **policy instance** is never memoised: each check asks the container for one, exactly as Laravel's Gate does on every `$user->can()`. Consequences:

- A policy may receive request-scoped dependencies through its constructor (a tenant context, the current principal, a per-request cache) and always sees the current values.
- Container bindings are honoured on the next check: `bind`, `instance()`, `singleton`, `scoped`. Want one instance per request for an expensive policy? Bind it `scoped` in your provider; Martis does not decide that for you.
- Nothing survives the application that created it, so a test suite that boots an application per test never sees a policy from an earlier test, and no `tearDown()` hook is needed.

`Resource::flushPolicyCache()` and `HasPolicy::flushPolicyCache()` still exist (they share the memo, either call clears everything). Reach for them only in a test that registers a Gate policy or swaps `$policy` *after* the class has already been resolved in that same application. Before v1.36.0 the instances themselves were cached in static arrays that outlived the application, and only Laravel Octane events flushed them; policies therefore had to be stateless to behave under PHPUnit or a queue worker.

## Per-request Gate cache

Off by default. Flip `MARTIS_AUTHZ_REQUEST_CACHE=true` and `Martis\Authorization\RequestScopedAbilityCache` records every Gate result keyed on `(user class, user id, ability, every argument)` for the duration of the request, by listening to `GateEvaluated`. It only **observes**: it does not short-circuit the Gate, and the package does not read it back yet, so enabling it does not by itself save any policy call. Resource checks (the sidebar, the schema authorization block, the per-record `_authorization` block, action visibility) call the policy directly and are not recorded at all. Host code can read the cache before a redundant check:

```
$cached = app(\Martis\Authorization\RequestScopedAbilityCache::class)->lookup($user, $ability, $model);
 
if ($cached === null) {
    // not cached yet: run the real check
}
```

`lookup()` takes the user, not its id (v2.0.1): the key holds the user's morph class as well as its identifier, so an admin and a site user who share an id (an `admins` guard beside the site's `users`) never read each other's answers. A call written for v2.0.0, `lookup($user->id, ...)`, now throws a `TypeError`: pass the user.

Every argument of the call is part of the key (v2.4.0): each model by class and key, each string, number or boolean by value, in order. An ability that takes several models, such as `attach{Model}($user, $parent, $related)` or `detach{Model}`, keeps one answer per combination, so the answer for one related record is never returned for another. Before v2.4.0 the key held the first model argument only, and the extra models of such an ability were left out of it. A call that holds an argument the cache cannot key is not cached and `lookup()` returns `null` for it: a model with no key or not stored (two different new records would share one key, and a policy reads their attributes), an array, a closure or any other object.

The cache is request-scoped — never spans requests, never persisted. Closure-only gates that depend on `Request` state are skipped (the cache key would be ambiguous). `null` results (no policy registered) are not cached so the next call still falls through to the default behaviour.

## Revoke sessions on demote

Off by default. When `MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTE=true` and the host app uses Laravel's `database` session driver, a Spatie `RoleDetachedEvent` or `PermissionDetachedEvent` triggers a session sweep on the demoted users — every active session row of theirs (across all devices) is dropped. The request's own session is never dropped: the operator (admin) stays signed in, also when they hold the role they just changed.

Who is swept depends on the model the Spatie event names (v2.4.0):

| Event | Model | Swept |
| --- | --- | --- |
| `RoleDetachedEvent`, `PermissionDetachedEvent` | a user of the Martis guard (a role or a permission removed from them) | that user |
| `PermissionDetachedEvent` | a **role** (a permission revoked from a role) | the users who hold the role (`Role::users()`), if they are the Martis guard's users |
| any | another model, or a role whose users are not the Martis guard's (a role of another guard) | nobody: the sweep is skipped and a warning names the model |

Up to v2.3.0 the sweep deleted the sessions whose `user_id` equalled the id of whatever model the event named. Revoking a permission from role 7 therefore signed out the unrelated user with id 7 and left the holders of the role, who had just lost the permission, signed in. A user is recognised as the Martis guard's by the model of the guard's provider, or by its table, so an app's `Staff` model beside its `User` on one table counts.

Use this in regulated apps where a demotion must take immediate effect on every device the user is signed in on, without waiting for the session cookie to expire.

The sweep deletes the session rows by the demoted user's id, and Laravel's `sessions.user_id` stores no table. When the session guards of `config/auth.php` (with the Martis and the default guard) sign in users of more than one table, such as a custom `MARTIS_GUARD` whose model has its own table beside the site's `users`, that id can be another person's, so the sweep is skipped and a warning names the tables (v2.0.0+). Guards that share one table keep the sweep, and so do the guards listed in [`martis.auth.session_guards`](https://getmartis.com/docs/reference/configuration#session-guards-v2230) (v2.23.0), the way out for an admin-only app that only has Laravel's merged `web` guard in the way: the list must be complete, or the sweep can delete the session of another person with the same id.

## Testing helpers

The `Martis\Testing\AssertsAuthorization` Pest / PHPUnit trait adds expressive helpers. They are thin wrappers over `$user->can()` / `$user->cannot()`, i.e. Laravel's Gate: that is what Tools and Dashboards use, while Resource checks call the policy directly, so for a Resource the helpers agree with the admin UI when the Gate resolves the same policy for the model and no `Gate::before()` / `after()` callback overrides it:

```
uses(\Martis\Testing\AssertsAuthorization::class);
 
it('admins can edit any post', function () {
    $admin = User::factory()->admin()->create();
    $post = Post::factory()->create();
    $this->assertCanUpdate($admin, $post);
    $this->assertCanDelete($admin, $post);
});
 
it('readers cannot mutate', function () {
    $reader = User::factory()->create();
    $post = Post::factory()->create();
    $this->assertCannotUpdate($reader, $post);
    $this->assertCannotDelete($reader, $post);
});
```

Available helpers: `assertCan` / `assertCannot` (generic), plus typed shortcuts for `view`, `viewAny`, `create`, `update`, `delete`, `restore`, `forceDelete`. Failure messages name the user, ability, and target model — instead of "Failed asserting that false is true." you get "Expected user 12 to be allowed to update Post #5, but the policy denied.".

## Tips

- A single `Policy::before(User $user, string $ability): ?bool` short-circuits all checks when it returns non-null. Useful for super-admin flags.
- `Resource::authorizedTo*()` methods call the resolved policy's methods directly (after its own `before()`), not Laravel's Gate. A host `Gate::before()` / `Gate::after()` callback (a typical super-admin shortcut) therefore does not apply to Resources: put that logic in each policy's `before()`, or in a shared base policy. The policy methods stay the single source of truth, but `$user->can('update', $post)` can differ from the admin UI when a Gate callback or a different Gate-registered policy is involved.
- For testing, the package ships an `AssertsAuthorization` Pest trait. See [Testing helpers](https://getmartis.com/docs/auth/authorization#testing-helpers) below.

Invitations

REST API
