Kontext / Ziel Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk). Links zur Spec: spec.md plan.md quickstart.md Was ist drin Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions) Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext) Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns) CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**: verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort) RBAC-UX Semantik (konsequent & testbar) Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards. Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects). Member mit Capability: Action enabled und ausführbar. Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)). Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes. Sicherheit / Scope Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP). Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership). Kein Asset-Setup erforderlich; keine neuen Filament Assets. Compliance Notes (Repo-Regeln) Filament v5 / Livewire v4.0+ kompatibel. Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert). Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR. Tests / Qualität Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard. Guard-Test: “No ad-hoc Filament auth patterns”. Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped. Checklist: requirements.md vollständig (16/16). Review-Fokus API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply() Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #81
263 lines
7.2 KiB
Markdown
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.
|