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

263 lines
7.2 KiB
Markdown

# 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`.
```php
// ❌ 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
```php
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
```php
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
```php
->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:
```php
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:
```php
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
```php
// 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()`
```php
// This does nothing!
UiEnforcement::forAction($action)
->requireCapability(Capabilities::PROVIDER_MANAGE);
// missing ->apply()
```
### ❌ Using with non-tenant panels
```php
// UiEnforcement is tenant-scoped only!
// For /system panel, use Gate::check() directly
```
### ❌ Mixing old and new patterns
```php
// Don't mix - pick one
UiEnforcement::forAction(
Action::make('sync')
->visible(fn () => someOtherCheck()) // ❌ conflict
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply();
```
## Questions?
See [spec.md](./spec.md) for full requirements or [plan.md](./plan.md) for implementation details.