Authorisation

Textstem's authorisation layer is built on spatie/laravel-permission and Laravel's Gate/Policy system. This document covers how the default system works and how to extend it with custom per-instance rules and admin list filtering.


How It Works

There are three levels of authorisation:

  1. Super-admin bypass -- users with the super-admin role pass all gates unconditionally.
  2. Admin entry -- the access-textstem-admin gate decides who can open the admin at all.
  3. Role-based permissions -- standard Spatie permissions checked in each policy method.
  4. Instance-level access -- optional custom classes that restrict which specific model records a user can act on.

On top of these, content approval decides who may publish pages and posts.


Super Admin

Any user assigned the super-admin role bypasses all gate checks. This is wired via Gate::before in the service provider:

Gate::before(function ($user, $ability) {
    if ($user->isSuperAdmin()) {
        return true;
    }
});

isSuperAdmin() is a method on the User model:

public function isSuperAdmin(): bool
{
    return $this->hasRole('super-admin');
}

Super-admins can also impersonate other users (except other super-admins).


Entering the Admin

Signing in isn't enough to open /textstem. The access-textstem-admin gate lets a user in when they hold any role listed in admin_roles:

Gate::define('access-textstem-admin', function ($user): bool {
    return $user->hasAnyRole(config('textstemapp.admin_roles', []));
});

The list is empty by default, so only super-admins get in until you add roles:

// config/textstemapp.php
'admin_roles' => ['admin', 'editor'],

When a site upgrades, a one-off migration gives every user who has no role at all a role: the first admin_roles entry that already exists as a role, otherwise the first entry (or admin when the list is empty), created if needed. Users only get into the admin if that role is also listed in admin_roles.

The dashboard itself checks a view-dashboard ability. No gate or permission of that name ships with the package, so only super-admins pass it unless you create a view-dashboard permission (Spatie registers each permission as a gate ability) and give it to your roles.


Built-in Policies

Textstem ships policies for its core models. They are found from the model's class name, so the package's own models need no registration:

Model Policy
WranglerPage WranglerPagePolicy
WranglerComponent WranglerComponentPolicy
Post PostPolicy
Asset AssetPolicy
Category CategoryPolicy
Collection CollectionPolicy
Event EventPolicy
Tag TagPolicy
User UserPolicy
GlobalOption GlobalOptionPolicy (every method checks manage settings)
Message MessagePolicy (only the sender and the recipient)
Admin tools ToolsPolicy (use tools)

Your own models need Gate::policy(). The package replaces Laravel's policy guessing for the whole app:

Gate::guessPolicyNamesUsing(function ($modelClass) {
    return 'Medialight\\Textstem\\Policies\\'.class_basename($modelClass).'Policy';
});

Every model, including your app's, is looked up as Medialight\Textstem\Policies\{Model}Policy, so App\Policies\ProductPolicy is never found by convention. Register host-app policies yourself, or authorize() calls on those models deny everyone but super-admins:

// AppServiceProvider::boot()
Gate::policy(\App\Models\Product::class, \App\Policies\ProductPolicy::class);

Policy Methods

Each policy implements the standard set of Laravel policy methods. The permission name used is derived from the action and the lowercase model name (plural or without spaces):

Method Checks permission Also checks instance access
viewAny list {model} No
view view {model} No
create create {model} No
update update {model} Yes (where supported)
edit update {model} (pages and posts only) Yes (pages)
publish publish {model} (pages and posts only) No
delete delete {model} Yes (where supported)
deleteAny delete {model} No
restore -- Always false
forceDelete -- Always false

Permission names by model

Model Permission names
WranglerPage list wranglerpages, view wranglerpages, create wranglerpages, update wranglerpages, delete wranglerpages
WranglerComponent list wranglercomponents, view wranglercomponents, create wranglercomponents, update wranglercomponents, delete wranglercomponents
Post list posts, view posts, create posts, update posts, delete posts
Asset list assets, view assets, create assets, update assets, delete assets
Category list categories, view categories, create categories, update categories, delete categories
Collection list collections, view collections, create collections, update collections, delete collections
Tag list tags, view tags, create tags, update tags, delete tags
Event list events, view events, create events, update events, delete events
User list users, view users, create users, update users, delete users

Pages and posts add one more each: publish wranglerpages and publish posts.

Publishing pages and posts

With content approval, update and delete on a live page or post also need the publish permission, and so do update and delete on a live page's blocks. edit only opens the editor, so people without the publish permission can open a live page read-only and make a draft of it. The full rules are in Content approval.

Ability Page Post
update update wranglerpages, instance access, and either publish wranglerpages or a page that is not live and not scheduled update posts, and either publish posts or a post that is not live and not scheduled
edit update wranglerpages and instance access update posts
delete delete wranglerpages, instance access, and publish wranglerpages when the page is live delete posts, and publish posts when the post is live
publish publish wranglerpages publish posts

The upgrade migrations give publish wranglerpages and publish posts to every role that could already update pages or posts, so nothing changes for a site until you take a publish permission away from a role.


Customising Authorisation

The built-in policies check permissions, but do not restrict access to individual model instances by default. For finer-grained control -- for example, limiting an editor to only pages in a specific category or URL prefix -- you can create a custom auth class in your host application.

Instance-Level Access

Create a class in App\Domain\Admin\Auth\ named after the model. The class must implement an allowed() method that receives the user, the model instance, and the action string, and returns a boolean.

For WranglerPage:

<?php

namespace App\Domain\Admin\Auth;

use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerPage as WranglerPageModel;

class WranglerPage
{
    public function allowed(User $user, WranglerPageModel $model, string $action = ''): bool
    {
        // Return true if this user can perform $action on this specific page.
        // Example: restrict to pages in a category the user is responsible for.
        return $user->hasRole('super-admin') || $model->category_id === $user->category_id;
    }
}

For WranglerComponent:

<?php

namespace App\Domain\Admin\Auth;

use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerComponent as WranglerComponentModel;

class WranglerComponent
{
    public function allowed(User $user, WranglerComponentModel $model, string $action = ''): bool
    {
        return true; // add your logic here
    }
}

When the policy runs checkModelAccess(), it detects your class and delegates to allowed(). The page policy calls it for update, edit and delete. If the class does not exist, all model instances are accessible to any user who holds the required permission.

The $action parameter will be the ability being checked (e.g. 'update', 'delete'), giving you the ability to differentiate between actions if needed.

Filtering Admin Lists

To control which records appear in admin listing views, add a scopes() method to the same auth class. The Pages table calls it with just the query builder (so the method must fetch the user itself) and only for users who aren't super-admins. Only WranglerPage has this hook; other lists aren't filtered.

<?php

namespace App\Domain\Admin\Auth;

use Illuminate\Database\Eloquent\Builder;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerPage as WranglerPageModel;

class WranglerPage
{
    public function allowed(User $user, WranglerPageModel $model, string $action = ''): bool
    {
        // ...
    }

    public function scopes(Builder &$query, ?User $user = null): void
    {
        if (! $user) {
            $user = auth()->user();
        }

        // Example: limit the list to pages under the user's regional URL prefix
        $region = $user->region; // your own attribute
        $query->where('url', 'LIKE', "/{$region}%");
    }
}

Supported Models for Custom Auth Classes

Custom auth classes are supported for the following models. The class name must exactly match the model base name:

Class to create Applies to
App\Domain\Admin\Auth\WranglerPage WranglerPage update, edit and delete; scopes() filters the Pages list
App\Domain\Admin\Auth\WranglerComponent WranglerComponent update and delete

Other policies (Post, Asset, Category, etc.) perform only permission checks and do not call checkModelAccess. To customise access for those models, extend the policy directly (see below).


Extending a Policy

If you need to override a full policy -- for example to add instance-level checks to PostPolicy -- extend the package class and bind it in a service provider in your host application:

<?php

namespace App\Policies;

use Medialight\Textstem\Models\Post;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Policies\PostPolicy as BasePostPolicy;

class PostPolicy extends BasePostPolicy
{
    public function update(User $user, Post $model): bool
    {
        return parent::update($user, $model) && $model->author === $user->name;
    }
}

Register it in AppServiceProvider::boot():

use App\Policies\PostPolicy;
use Medialight\Textstem\Models\Post;

Gate::policy(Post::class, PostPolicy::class);

Checking Authorisation in Your Code

Standard Laravel patterns apply throughout:

// In a controller or Livewire component
$this->authorize('update', $page);

// In a Blade view
@can('create', \Medialight\Textstem\Models\WranglerPage::class)
    <a href="{{ route('wrangler-pages.create') }}">New page</a>
@endcan

// Programmatic check
if (Gate::allows('delete', $post)) {
    // ...
}

Summary

Scenario Solution
Full admin access Assign the super-admin role
Let a role into the admin Add it to admin_roles
Editors who can't publish Remove publish wranglerpages / publish posts from their role
Policies for your own models Register them with Gate::policy()
Restrict to certain actions Assign/remove Spatie permissions
Restrict to certain model instances Create App\Domain\Admin\Auth\{Model} with allowed()
Filter the admin list Add scopes() to the same class
Override a policy entirely Extend the package policy and register with Gate::policy()
esc