TenantAtlas/specs/066-rbac-ui-enforcement-helper/quickstart.md
2026-01-30 17:39:03 +01:00

7.2 KiB

Quickstart: UiEnforcement Helper

Adoption guide for developers adding RBAC enforcement to Filament actions.

TL;DR

Replace ad-hoc ->visible(fn ...) / ->disabled(fn ...) closures with UiEnforcement.

// ❌ Before (ad-hoc)
Action::make('sync')
    ->visible(fn () => auth()->user()->can('provider:manage', Filament::getTenant()))
    ->disabled(fn () => ! auth()->user()->can('provider:manage', Filament::getTenant()))
    ->action(function () {
        // no server-side guard
    });

// ✅ After (UiEnforcement)
UiEnforcement::forAction(
    Action::make('sync')
        ->action(fn () => $this->sync())
)
    ->requireCapability(Capabilities::PROVIDER_MANAGE)
    ->apply();

When to Use

Scenario Use UiEnforcement?
Tenant-scoped action (header, table, bulk) Yes
Platform-scoped action (/system panel) No (use Gate directly)
Read-only navigation link No (use ->visible() for nav items)
Destructive action (delete, detach, restore) Yes, with ->destructive()

API Reference

Header / Page Actions

use App\Support\Rbac\UiEnforcement;
use App\Support\Auth\Capabilities;

protected function getHeaderActions(): array
{
    return [
        UiEnforcement::forAction(
            Action::make('createBackup')
                ->action(fn () => $this->createBackup())
        )
            ->requireCapability(Capabilities::BACKUP_CREATE)
            ->apply(),

        UiEnforcement::forAction(
            Action::make('deleteAllBackups')
                ->action(fn () => $this->deleteAll())
        )
            ->requireCapability(Capabilities::BACKUP_MANAGE)
            ->destructive()
            ->apply(),
    ];
}

Table Row Actions

public static function table(Table $table): Table
{
    return $table
        ->columns([...])
        ->actions([
            UiEnforcement::forTableAction(
                Action::make('restore')
                    ->action(fn (Policy $record) => $record->restore()),
                fn () => $this->getRecord()  // record accessor
            )
                ->requireCapability(Capabilities::RESTORE_EXECUTE)
                ->destructive()
                ->apply(),
        ]);
}

Bulk Actions

->bulkActions([
    UiEnforcement::forBulkAction(
        BulkAction::make('deleteSelected')
            ->action(fn (Collection $records) => $records->each->delete())
    )
        ->requireCapability(Capabilities::TENANT_DELETE)
        ->destructive()
        ->apply(),
])

Behavior Matrix

User Status UI State Server Response
Non-member Hidden Blocked (no execution, 200)
Member, no capability Visible, disabled + tooltip Blocked (no execution, 200)
Member, has capability Enabled Executes
Member, destructive action Confirmation modal Executes after confirm

Note on 404/403 Responses: In Filament v5, hidden actions are automatically treated as disabled, so execution is blocked silently (returns 200 with no side effects). True 404 enforcement happens at the page/routing level via tenant middleware. The UiEnforcement helper includes defense-in-depth server-side guards that abort(404/403) if somehow reached, but the primary protection is Filament's isHidden/isDisabled chain.

Tooltip Customization

Default tooltip: "You don't have permission to do this. Ask a tenant admin."

Override per-action:

UiEnforcement::forAction($action)
    ->requireCapability(Capabilities::PROVIDER_MANAGE)
    ->tooltip('Contact your organization owner to enable this feature.')
    ->apply();

Testing

Test both UI state and execution blocking:

it('hides sync action for non-members', function () {
    $user = User::factory()->create();
    $tenant = Tenant::factory()->create();
    // user is NOT a member

    actingAs($user);
    Filament::setTenant($tenant, true);

    Livewire::test(ListPolicies::class)
        ->assertActionHidden('sync');
});

it('blocks action execution for non-members (no side effects)', function () {
    $user = User::factory()->create();
    $tenant = Tenant::factory()->create();
    Queue::fake();

    actingAs($user);
    Filament::setTenant($tenant, true);

    // Hidden actions are blocked silently (200 but no execution)
    Livewire::test(ListPolicies::class)
        ->mountAction('sync')
        ->callMountedAction()
        ->assertSuccessful();

    // Verify no side effects occurred
    Queue::assertNothingPushed();
});

it('disables sync action for readonly members', function () {
    [$user, $tenant] = createUserWithTenant(role: 'readonly');

    actingAs($user);
    Filament::setTenant($tenant, true);

    Livewire::test(ListPolicies::class)
        ->assertActionVisible('sync')
        ->assertActionDisabled('sync');
});

it('shows disabled tooltip for readonly members', function () {
    [$user, $tenant] = createUserWithTenant(role: 'readonly');

    actingAs($user);
    Filament::setTenant($tenant, true);

    Livewire::test(ListPolicies::class)
        ->assertActionHasTooltip('sync', UiTooltips::INSUFFICIENT_PERMISSION);
});

Migration Checklist

When migrating an existing action:

  • Remove ->visible(fn ...) closure (UiEnforcement handles this)
  • Remove ->disabled(fn ...) closure (UiEnforcement handles this)
  • Remove inline Gate::check() / abort_unless() from action handler
  • Wrap action with UiEnforcement::forAction(...)->requireCapability(...)->apply()
  • Add ->destructive() if action modifies/deletes data
  • Add test for non-member (hidden + no execution)
  • Add test for member without capability (disabled + tooltip)
  • Add test for member with capability (enabled + executes)

Real Example: ListPolicies Sync Action

// Before (ad-hoc)
Action::make('sync')
    ->icon('heroicon-o-arrow-path')
    ->action(function (): void {
        $tenant = Tenant::current();
        if (! $tenant) {
            return;
        }
        // ... sync logic
    })
    ->disabled(fn (): bool => ! Gate::allows(Capabilities::TENANT_SYNC, Tenant::current()))

// After (UiEnforcement)
UiEnforcement::forAction(
    Action::make('sync')
        ->icon('heroicon-o-arrow-path')
        ->action(function (): void {
            $tenant = Tenant::current();
            if (! $tenant) {
                return;
            }
            // ... sync logic
        })
)
    ->requireCapability(Capabilities::TENANT_SYNC)
    ->destructive()
    ->apply()

Common Mistakes

Forgetting ->apply()

// This does nothing!
UiEnforcement::forAction($action)
    ->requireCapability(Capabilities::PROVIDER_MANAGE);
    // missing ->apply()

Using with non-tenant panels

// UiEnforcement is tenant-scoped only!
// For /system panel, use Gate::check() directly

Mixing old and new patterns

// Don't mix - pick one
UiEnforcement::forAction(
    Action::make('sync')
        ->visible(fn () => someOtherCheck())  // ❌ conflict
)
    ->requireCapability(Capabilities::PROVIDER_MANAGE)
    ->apply();

Questions?

See spec.md for full requirements or plan.md for implementation details.