Security

Authorization

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

Filament Builder Authorization

This document covers authorization patterns for the Tagixo builder when integrated with Filament (via ccast/tagixo-filament). All eight topics below apply to both the standard Filament resource pages and any custom builder page classes.


1. Builder Page Authorization

authorize() override with Gate::authorize()

Builder pages extend Filament\Resources\Pages\Page (or a Tagixo subclass of it). Override authorize() to gate access declaratively:

use Illuminate\Support\Facades\Gate;

class EditPageBuilder extends \Ccast\TagixoFilament\Pages\BuilderPage
{
    protected function authorize(): void
    {
        Gate::authorize('update', $this->getRecord());
    }
}

Gate::authorize() throws AuthorizationException on failure, which Filament converts to a 403 response.

mount() override for redirect-based gating

When you prefer a redirect over a 403 (e.g. to send the user to a "request access" page), override mount() instead:

public function mount(int|string $record): void
{
    parent::mount($record);

    if (! Gate::allows('update', $this->getRecord())) {
        $this->redirect(route('filament.admin.pages.access-denied'));
    }
}

Always call parent::mount() first so the record is resolved before you check permissions.

$this->authorizeAccess() after record resolution

For resource sub-pages (Edit, View), Filament provides authorizeAccess() which is called automatically after mount() resolves the record. You can call it manually if you need to re-check mid-lifecycle:

public function someLifecycleHook(): void
{
    $this->authorizeAccess();
    // ... proceed
}

authorizeAccess() delegates to the resource's policy (see §2).


2. Resource-Based Authorization

Binding a PagePolicy

Register your policy in AuthServiceProvider:

use App\Models\Page;
use App\Policies\PagePolicy;

protected $policies = [
    Page::class => PagePolicy::class,
];

A minimal policy for builder access:

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

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

    public function create(User $user): bool
    {
        return $user->hasRole('admin');
    }

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

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

How resource pages inherit canView / canEdit

Filament's EditRecord and ViewRecord call $resource::canEdit($record) and $resource::canView($record) respectively. These methods resolve to the policy's update and view gates automatically when a policy is registered — no extra wiring needed. Custom builder pages that extend BuilderPage follow the same convention if they call parent::mount().


3. Preview URL Authorization

Two patterns exist for previewing unpublished builder content. Choose based on whether the viewer needs to be authenticated.

Pattern A — Signed URL (unauthenticated viewer)

Suitable for sharing a draft with a client who has no admin account.

use Illuminate\Support\Facades\URL;

// Generate in the builder action:
$url = URL::temporarySignedRoute(
    'pages.preview',
    now()->addHours(2),
    ['page' => $this->getRecord()->id]
);

Route definition:

Route::get('/preview/{page}', PreviewController::class)
    ->name('pages.preview')
    ->middleware('signed');

The signed middleware rejects requests where the signature is missing or the URL has expired, returning 403.

Pattern B — Authenticated preview (panel middleware)

For in-panel previews, pass the record ID on a panel-protected route and rely on the existing Authenticate middleware. No signed URL needed — the user is already logged in and the policy gates the record.

Route::get('/admin/pages/{page}/preview', AdminPreviewController::class)
    ->middleware(['web', 'auth']);

Comparison

Signed URL Authenticated preview
Viewer needs account No Yes
Expiry Configurable Session lifetime
Suitable for clients Yes No
Revokable Not natively (use short TTL) Yes (revoke session/policy)

4. Upload Authorization

Registering Tagixo::authorizeUpload()

In a service provider (e.g. AppServiceProvider::boot()):

use Ccast\Tagixo\Facades\Tagixo;

Tagixo::authorizeUpload(function (\Illuminate\Http\Request $request): bool {
    return $request->user()?->hasAnyRole(['editor', 'admin']) ?? false;
});

The callback receives the current Request and must return a boolean. Returning false causes the upload endpoint to respond with 403.

Private media serving via signed URL controllers

If uploaded assets are stored on a private disk (e.g. s3 with no public ACL), do not expose them through a public URL. Instead, create a signed-URL controller:

class MediaDownloadController
{
    public function __invoke(Media $media)
    {
        abort_unless(Gate::allows('view', $media), 403);

        return redirect(
            Storage::disk('s3')->temporaryUrl($media->path, now()->addMinutes(15))
        );
    }
}

Register it on an auth-protected route so the gate check has an authenticated user.


5. Per-Record Context

Team / tenant scope check before payload loads

When using multi-tenancy, verify the record belongs to the current tenant before the builder payload is returned. Override mount() or authorizeAccess():

public function mount(int|string $record): void
{
    parent::mount($record);

    $page = $this->getRecord();

    abort_unless($page->team_id === auth()->user()->current_team_id, 403);
}

This runs after Filament resolves the record but before the builder Vue app boots, so the payload is never sent to an unauthorized user.

Eloquent query scoping on the resource

Add a global scope or a local scope on the resource's getEloquentQuery() for defense-in-depth:

// In your Filament Resource class:
public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()
        ->where('team_id', auth()->user()->current_team_id);
}

This prevents direct record ID enumeration even if a per-record gate check were skipped.


6. Panel-Level Auth

canAccess() to hide pages from navigation and block URL access

Custom panel pages (not resource pages) expose a static canAccess() method:

class ManageBuilderSettings extends \Filament\Pages\Page
{
    public static function canAccess(): bool
    {
        return auth()->user()?->hasRole('admin') ?? false;
    }
}

When canAccess() returns false:

  • The page is removed from the navigation menu.
  • A direct URL visit returns 403.

Note: $this is not available in canAccess() because it is called statically (e.g. during navigation building before any page instance exists). Resolve the authenticated user via auth()->user() or request()->user().


7. CSRF and the Save Endpoint

Why CSRF alone is not enough

Livewire (which underpins Filament) automatically includes the CSRF token in all Livewire network requests and validates it on the server. You do not need to add @csrf manually to Livewire components, and you do not need to exempt builder Livewire routes from CSRF protection.

However, CSRF validation only confirms the request originated from your own front-end. It does not verify who the user is or what they are allowed to do.

The correct guard: panel Authenticate middleware

All Filament panel routes are protected by the Authenticate middleware registered in the panel provider. This middleware:

  1. Confirms the user is logged in (redirects to login if not).
  2. Confirms the user can access the panel via canAccessPanel().

For the builder save action, rely on this middleware plus your policy gates — not on CSRF alone. A typical save action in a builder page:

protected function save(): void
{
    $this->authorize('update', $this->getRecord());

    // ... persist the builder payload
}

The call to $this->authorize() triggers the PagePolicy::update gate, which is the correct place to enforce who can write builder data.


8. Complete Multi-Role Example

The following example shows a PagePolicy, a custom EditPageBuilder page, and two page actions — Save Draft (editor + admin) and Publish (admin only) — with both ->visible() and ->authorize() closures.

app/Policies/PagePolicy.php

<?php

namespace App\Policies;

use App\Models\User;
use Ccast\Tagixo\Models\Page;

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

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

    public function create(User $user): bool
    {
        return $user->hasRole('admin');
    }

    public function update(User $user, Page $page): bool
    {
        // Both editors and admins can edit draft or published pages.
        return $user->hasAnyRole(['editor', 'admin']);
    }

    public function publish(User $user, Page $page): bool
    {
        // Only admins may publish.
        return $user->hasRole('admin');
    }

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

Register in AuthServiceProvider:

protected $policies = [
    \Ccast\Tagixo\Models\Page::class => \App\Policies\PagePolicy::class,
];

app/Filament/Resources/PageResource/Pages/EditPageBuilder.php

<?php

namespace App\Filament\Resources\PageResource\Pages;

use App\Filament\Resources\PageResource;
use Ccast\TagixoFilament\Pages\BuilderPage;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Gate;

class EditPageBuilder extends BuilderPage
{
    protected static string $resource = PageResource::class;

    protected function authorize(): void
    {
        // Editors and admins may open the builder.
        Gate::authorize('update', $this->getRecord());
    }

    protected function getHeaderActions(): array
    {
        return [
            // --- Save Draft ---
            Action::make('saveDraft')
                ->label('Save Draft')
                ->color('gray')
                // Hide the button entirely for users who cannot update.
                ->visible(fn () => Gate::allows('update', $this->getRecord()))
                // Also enforce server-side on submission.
                ->authorize(fn () => Gate::allows('update', $this->getRecord()))
                ->action(function (): void {
                    $this->getRecord()->update([
                        'status' => 'draft',
                        'builder_payload' => $this->getBuilderPayload(),
                    ]);

                    $this->notify('success', 'Draft saved.');
                }),

            // --- Publish ---
            Action::make('publish')
                ->label('Publish')
                ->color('success')
                // Only admins see the Publish button.
                ->visible(fn () => Gate::allows('publish', $this->getRecord()))
                // Enforce server-side even if the button somehow appears.
                ->authorize(fn () => Gate::allows('publish', $this->getRecord()))
                ->requiresConfirmation()
                ->action(function (): void {
                    $this->getRecord()->update([
                        'status'           => 'published',
                        'published_at'     => now(),
                        'builder_payload'  => $this->getBuilderPayload(),
                    ]);

                    $this->notify('success', 'Page published.');
                }),
        ];
    }
}

Key points:

  • ->visible() controls UI visibility (button appears/disappears). Always pair it with ->authorize() so a user cannot trigger the action by crafting a direct Livewire call.
  • Both closures receive no arguments here; use $this->getRecord() inside the closure because the page instance is in scope via $this.
  • The authorize() method on the page class gates the entire page mount, while the per-action ->authorize() closures gate individual actions — both layers are necessary.