Security

Authorization

Gate builder access and preview URLs using Primix policies and custom authorize hooks.

Authorization in Primix

Primix sits on top of Laravel's standard authorization primitives — gates, policies, and middleware — but the visual builder and the save endpoint introduce a few layers that are not obvious from the Filament documentation alone. This guide walks through each layer from coarsest to finest, with patterns and gotchas for each.


1. BuilderPage page-level access

Every Primix page class (extending BuilderPage or any of the resource page variants) can override canAccess() to decide whether a user may visit that URL at all. When the method returns false, Primix redirects to the panel home and removes the item from navigation automatically — no separate navigationVisible() call needed.

use Primix\Pages\BuilderPage;
use Illuminate\Support\Facades\Gate;

class SiteSettingsPage extends BuilderPage
{
    protected static string $view = 'primix.pages.site-settings';

    public static function canAccess(): bool
    {
        // Gate-based: define a gate in AuthServiceProvider
        return Gate::allows('manage-site-settings');
    }
}

Role-based alternative using a simple helper:

public static function canAccess(): bool
{
    return auth()->user()?->hasRole(['admin', 'editor']) ?? false;
}

Navigation side effect: when canAccess() returns false, Primix strips the page from the sidebar. You do not need a separate navigationVisible() override. However, if you want the page visible but protected by a confirmation (e.g. 2FA prompt), keep canAccess() returning true and gate the content inside the page view or mount hook instead.


2. PrimixVisualBuilderPage resource authorization

When a resource page extends PrimixVisualBuilderPage, Primix wires the resource's Laravel policy in automatically. Reads trigger view, saves trigger update, and deletions trigger delete on the underlying model. You do not need to call the policy methods yourself inside the page.

If you want to layer a builder-specific check on top of the resource policy — for example, allowing editors to view but not open the visual builder — override canAccess() and delegate to parent::canAccess() first:

use Ccast\Tagixo\Primix\Pages\PrimixVisualBuilderPage;

class EditPageBuilder extends PrimixVisualBuilderPage
{
    public static function canAccess(): bool
    {
        // Must pass the resource-level policy first
        if (! parent::canAccess()) {
            return false;
        }

        // Additional builder-specific check
        return auth()->user()?->can('use-visual-builder') ?? false;
    }
}

Skipping parent::canAccess() bypasses the resource policy entirely. Always call it unless you are intentionally replacing the policy check with a stricter custom rule.


3. Per-record ownership checks

The resource policy is the primary mechanism for per-record authorization. Primix passes the record to the policy's view, update, and delete methods automatically when loading or saving a resource page. Define your ownership logic there:

// app/Policies/PagePolicy.php
class PagePolicy
{
    public function update(User $user, Page $page): bool
    {
        return $user->hasRole('admin') || $page->created_by === $user->id;
    }
}

For defence-in-depth, you can also add an ownership check inside saveStructure() on a custom builder page. This is not strictly necessary when your policy is registered, but it guards against misconfiguration:

protected function saveStructure(array $payload): void
{
    $record = $this->getRecord();

    abort_unless(
        auth()->user()->can('update', $record),
        403,
        'You do not have permission to save this page.'
    );

    parent::saveStructure($payload);
}

Keep this to a single abort_unless call. Do not replicate full ownership logic here — that belongs in the policy.


4. Panel-level role access

Panel-level checks act as the outermost gate. They run before any page or resource check. Configure them in your AdminPanelProvider:

->authGuard('web')               // which guard to authenticate against
->authMiddleware([
    Authenticate::class,
    // Custom middleware that checks for an "admin" role
    EnsureUserIsAdmin::class,
])

Panel-level checks are necessary even when every page and resource has its own canAccess(). Without a panel-level guard, an unauthenticated request can probe any panel route. The two layers serve different purposes: the panel middleware is for blanket authentication and role-presence checks, while page and resource authorization is for fine-grained capability checks within the panel.

A minimal EnsureUserIsAdmin middleware looks like this:

class EnsureUserIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->hasRole('admin')) {
            abort(403);
        }

        return $next($request);
    }
}

5. The save endpoint

Saves initiated from the visual builder go through the LiVue component lifecycle, not through a dedicated public route. The save request is session-authenticated (the browser session that loaded the panel) and CSRF-protected by Laravel's standard middleware. You do not need to register an extra route or add a token to the builder configuration.

What this means in practice:

  • A user who can reach the builder page is already authenticated and CSRF-verified before the save fires.
  • The save still passes through the resource policy's update check (see section 2 and 3).
  • You do not need to add sanctum or api middleware to builder save requests.

If you see 419 (CSRF mismatch) or 401 (unauthenticated) on saves, the root cause is almost always a misconfigured session driver, a missing VerifyCsrfToken middleware on the panel routes, or a reverse proxy stripping cookies — not a missing authorization layer.


6. Preview URL signing

Draft pages and unpublished content can be previewed via a signed URL so that editors can share a link with stakeholders who are not logged in to the admin panel.

Generate a signed preview URL from a Primix action or a controller:

use Illuminate\Support\Facades\URL;

$previewUrl = URL::temporarySignedRoute(
    'page.preview',
    now()->addHours(4),
    ['page' => $page->id]
);

In the preview controller, verify the signature and resolve the page:

public function show(Request $request, Page $page): Response
{
    abort_unless($request->hasValidSignature(), 403);

    // Render the page without checking published status
    return response(PageRenderer::renderWithLayout($page));
}

For session-gated previews (only logged-in panel users), skip signing and use a plain authenticated route instead:

// routes/web.php
Route::get('/admin/preview/{page}', [PreviewController::class, 'show'])
    ->middleware(['web', 'auth'])
    ->name('admin.page.preview');

Signed URLs have an expiry; plain authenticated routes last as long as the session. Choose based on whether the recipient has a panel account.


7. Upload authorization

Media uploads in the builder are gated by an instance method on the builder page class. Override canUploadMedia() to restrict who may upload while still allowing everyone to browse the existing library:

protected function canUploadMedia(): bool
{
    return auth()->user()?->hasRole(['admin', 'editor']) ?? false;
}

Gotcha — instance not static: canUploadMedia() is an instance method, not a static one. This means it has access to $this->getRecord() if you need per-record gating. It also means it runs after the page is fully mounted, so the user is guaranteed to be authenticated when it fires.

If you want to disable the upload button entirely for a page regardless of role, return false unconditionally:

protected function canUploadMedia(): bool
{
    return false;
}

8. Complete example: three-tier role model

The following example implements editor / admin / viewer tiers on a PageBuilderPage resource. Admins can do everything, editors can view and use the builder but cannot delete, viewers can only preview via a signed URL, and unauthenticated users cannot reach any of these routes.

Policy

// app/Policies/PagePolicy.php
class PagePolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasAnyRole(['admin', 'editor', 'viewer']);
    }

    public function view(User $user, Page $page): bool
    {
        return $user->hasAnyRole(['admin', 'editor', 'viewer']);
    }

    public function update(User $user, Page $page): bool
    {
        return $user->hasAnyRole(['admin', 'editor']);
    }

    public function delete(User $user, Page $page): bool
    {
        return $user->hasRole('admin');
    }
}

Builder page

// app/Primix/Resources/Pages/EditPage.php
class EditPage extends PrimixVisualBuilderPage
{
    public static function canAccess(): bool
    {
        if (! parent::canAccess()) {
            return false;
        }

        // Viewers reach the resource list but not the builder
        return auth()->user()?->hasAnyRole(['admin', 'editor']) ?? false;
    }

    protected function canUploadMedia(): bool
    {
        return auth()->user()?->hasRole('admin') ?? false;
    }

    // Defence-in-depth save guard
    protected function saveStructure(array $payload): void
    {
        abort_unless(auth()->user()->can('update', $this->getRecord()), 403);
        parent::saveStructure($payload);
    }
}

Signed preview URL (admin action)

// app/Primix/Resources/PageResource.php — inside table actions
Tables\Actions\Action::make('preview')
    ->label('Share Preview')
    ->icon('heroicon-o-share')
    ->action(function (Page $record): void {
        $url = URL::temporarySignedRoute(
            'page.preview',
            now()->addHours(8),
            ['page' => $record->id]
        );

        Notifications\Notification::make()
            ->title('Preview link copied')
            ->body($url)
            ->success()
            ->send();
    })
    ->visible(fn (Page $record): bool => auth()->user()->can('update', $record)),

Preview controller

// app/Http/Controllers/PagePreviewController.php
class PagePreviewController extends Controller
{
    public function show(Request $request, Page $page): Response
    {
        abort_unless($request->hasValidSignature(), 403);

        return response(PageRenderer::renderWithLayout($page));
    }
}

Summary

Layer Mechanism When it runs
Panel access authMiddleware in AdminPanelProvider Every request to /admin/*
Page visibility and access canAccess() override Before page mount; controls nav item
Resource CRUD Laravel policy (view, update, delete) On resource page load and save
Builder-specific access canAccess() calling parent::canAccess() Same as page access
Per-record save guard saveStructure() + abort_unless On builder save; defence-in-depth only
Upload gating canUploadMedia() instance method When the media picker is opened
Unauthenticated preview URL::temporarySignedRoute() On preview route hit; expiry-checked

See also

  • resources/docs/primix/builder-page.md — BuilderPage lifecycle and mount hooks
  • resources/docs/primix/resource-pages.md — PrimixVisualBuilderPage and resource wiring
  • resources/docs/primix/actions.md — Table and page actions, including share/export patterns
  • Laravel authorization documentation — policies, gates, signed URLs