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
415 lines
12 KiB
PHP
415 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Rbac;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use Closure;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use ReflectionObject;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Central RBAC UI Enforcement Helper for Filament Actions.
|
|
*
|
|
* Enforces constitution RBAC-UX rules:
|
|
* - Non-member → hidden UI + 404 server-side
|
|
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
|
* - Member with capability → enabled
|
|
* - Destructive actions → requiresConfirmation()
|
|
*
|
|
* @see \App\Support\Rbac\UiTooltips
|
|
* @see \App\Support\Rbac\TenantAccessContext
|
|
*/
|
|
final class UiEnforcement
|
|
{
|
|
private Action|BulkAction $action;
|
|
|
|
private bool $requireMembership = true;
|
|
|
|
private ?string $capability = null;
|
|
|
|
private bool $isDestructive = false;
|
|
|
|
private ?string $customTooltip = null;
|
|
|
|
private Model|Closure|null $record = null;
|
|
|
|
private ?Collection $records = null;
|
|
|
|
private bool $isBulk = false;
|
|
|
|
private bool $preserveExistingVisibility = false;
|
|
|
|
private function __construct(Action|BulkAction $action)
|
|
{
|
|
$this->action = $action;
|
|
}
|
|
|
|
/**
|
|
* Create enforcement for a header/page action.
|
|
*
|
|
* @param Action $action The Filament action to wrap
|
|
*/
|
|
public static function forAction(Action $action): self
|
|
{
|
|
return new self($action);
|
|
}
|
|
|
|
/**
|
|
* Create enforcement for a table row action.
|
|
*
|
|
* @param Action $action The Filament action to wrap
|
|
* @param Model|Closure $record The record or a closure that returns the record
|
|
*/
|
|
public static function forTableAction(Action $action, Model|Closure $record): self
|
|
{
|
|
$instance = new self($action);
|
|
$instance->record = $record;
|
|
|
|
return $instance;
|
|
}
|
|
|
|
/**
|
|
* Create enforcement for a bulk action with all-or-nothing semantics.
|
|
*
|
|
* If any selected record fails the capability check for a member,
|
|
* the action is disabled entirely.
|
|
*
|
|
* @param BulkAction $action The Filament bulk action to wrap
|
|
*/
|
|
public static function forBulkAction(BulkAction $action): self
|
|
{
|
|
$instance = new self($action);
|
|
$instance->isBulk = true;
|
|
|
|
return $instance;
|
|
}
|
|
|
|
/**
|
|
* Require tenant membership for this action.
|
|
*
|
|
* @param bool $require Whether membership is required (default: true)
|
|
*/
|
|
public function requireMembership(bool $require = true): self
|
|
{
|
|
$this->requireMembership = $require;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Require a specific capability for this action.
|
|
*
|
|
* @param string $capability A capability constant from Capabilities class
|
|
*
|
|
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
|
*/
|
|
public function requireCapability(string $capability): self
|
|
{
|
|
if (! Capabilities::isKnown($capability)) {
|
|
throw new \InvalidArgumentException(
|
|
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
|
);
|
|
}
|
|
|
|
$this->capability = $capability;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Mark this action as destructive (requires confirmation modal).
|
|
*/
|
|
public function destructive(): self
|
|
{
|
|
$this->isDestructive = true;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Override the default tooltip for disabled actions.
|
|
*
|
|
* @param string $message Custom tooltip message
|
|
*/
|
|
public function tooltip(string $message): self
|
|
{
|
|
$this->customTooltip = $message;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Preserve the action's existing visibility logic.
|
|
*
|
|
* Use this when the action already has business-logic visibility
|
|
* (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept.
|
|
*
|
|
* UiEnforcement will combine the existing visibility condition with tenant
|
|
* membership visibility, instead of overwriting it.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function preserveVisibility(): self
|
|
{
|
|
$this->preserveExistingVisibility = true;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Apply all enforcement rules to the action and return it.
|
|
*
|
|
* This sets up:
|
|
* - UI visibility (hidden for non-members)
|
|
* - UI disabled state + tooltip (for members without capability)
|
|
* - Destructive confirmation (if marked)
|
|
* - Server-side guards (404/403)
|
|
*
|
|
* @return Action|BulkAction The configured action
|
|
*/
|
|
public function apply(): Action|BulkAction
|
|
{
|
|
$this->applyVisibility();
|
|
$this->applyDisabledState();
|
|
$this->applyDestructiveConfirmation();
|
|
$this->applyServerSideGuard();
|
|
|
|
return $this->action;
|
|
}
|
|
|
|
/**
|
|
* Hide action for non-members.
|
|
*
|
|
* Skipped if preserveVisibility() was called.
|
|
*/
|
|
private function applyVisibility(): void
|
|
{
|
|
if (! $this->requireMembership) {
|
|
return;
|
|
}
|
|
|
|
$existingVisibility = $this->preserveExistingVisibility
|
|
? $this->getExistingVisibilityCondition()
|
|
: null;
|
|
|
|
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
|
|
$context = $this->resolveContextWithRecord($record);
|
|
|
|
if (! $context->isMember) {
|
|
return false;
|
|
}
|
|
|
|
if ($existingVisibility === null) {
|
|
return true;
|
|
}
|
|
|
|
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt to retrieve the existing visibility condition from the action.
|
|
*
|
|
* Filament stores this as the protected property `$isVisible` (bool|Closure)
|
|
* on actions via the CanBeHidden concern.
|
|
*/
|
|
private function getExistingVisibilityCondition(): bool|Closure|null
|
|
{
|
|
try {
|
|
$ref = new ReflectionObject($this->action);
|
|
if (! $ref->hasProperty('isVisible')) {
|
|
return null;
|
|
}
|
|
|
|
$property = $ref->getProperty('isVisible');
|
|
$property->setAccessible(true);
|
|
|
|
/** @var bool|Closure $value */
|
|
$value = $property->getValue($this->action);
|
|
|
|
return $value;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate an existing bool|Closure visibility condition.
|
|
*
|
|
* This is a best-effort evaluator for business visibility closures.
|
|
* If the closure cannot be evaluated safely, we fail closed (return false).
|
|
*/
|
|
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
|
{
|
|
if (is_bool($condition)) {
|
|
return $condition;
|
|
}
|
|
|
|
try {
|
|
$reflection = new \ReflectionFunction($condition);
|
|
$parameters = $reflection->getParameters();
|
|
|
|
if ($parameters === []) {
|
|
return (bool) $condition();
|
|
}
|
|
|
|
if ($record === null) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) $condition($record);
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable action for members without capability.
|
|
*/
|
|
private function applyDisabledState(): void
|
|
{
|
|
if ($this->capability === null) {
|
|
return;
|
|
}
|
|
|
|
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
|
|
|
$this->action->disabled(function (?Model $record = null) {
|
|
$context = $this->resolveContextWithRecord($record);
|
|
|
|
// Non-members are hidden, so this only affects members
|
|
if (! $context->isMember) {
|
|
return true;
|
|
}
|
|
|
|
return ! $context->hasCapability;
|
|
});
|
|
|
|
// Only show tooltip when actually disabled
|
|
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
|
$context = $this->resolveContextWithRecord($record);
|
|
|
|
if ($context->isMember && ! $context->hasCapability) {
|
|
return $tooltip;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add confirmation modal for destructive actions.
|
|
*/
|
|
private function applyDestructiveConfirmation(): void
|
|
{
|
|
if (! $this->isDestructive) {
|
|
return;
|
|
}
|
|
|
|
$this->action->requiresConfirmation();
|
|
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
|
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
|
}
|
|
|
|
/**
|
|
* Wrap the action handler with server-side authorization guard.
|
|
*
|
|
* This is a defense-in-depth measure. In normal operation, Filament's
|
|
* isDisabled() check prevents execution. This guard catches edge cases
|
|
* where the disabled check might be bypassed.
|
|
*/
|
|
private function applyServerSideGuard(): void
|
|
{
|
|
$this->action->before(function (?Model $record = null): void {
|
|
$context = $this->resolveContextWithRecord($record);
|
|
|
|
// Non-member → 404 (deny-as-not-found)
|
|
if ($context->shouldDenyAsNotFound()) {
|
|
abort(404);
|
|
}
|
|
|
|
// Member without capability → 403 (forbidden)
|
|
if ($context->shouldDenyAsForbidden()) {
|
|
abort(403);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the current access context with an optional record.
|
|
*/
|
|
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
|
{
|
|
$user = auth()->user();
|
|
|
|
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
|
$tenant = $this->resolveTenantWithRecord($record);
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
return new TenantAccessContext(
|
|
user: null,
|
|
tenant: null,
|
|
isMember: false,
|
|
hasCapability: false,
|
|
);
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
$isMember = $resolver->isMember($user, $tenant);
|
|
|
|
$hasCapability = true;
|
|
if ($this->capability !== null && $isMember) {
|
|
$hasCapability = $resolver->can($user, $tenant, $this->capability);
|
|
}
|
|
|
|
return new TenantAccessContext(
|
|
user: $user,
|
|
tenant: $tenant,
|
|
isMember: $isMember,
|
|
hasCapability: $hasCapability,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the tenant for this action with an optional record.
|
|
*
|
|
* Priority:
|
|
* 1. If $record is passed and is a Tenant, use it
|
|
* 2. If $this->record is set (for forTableAction), resolve it
|
|
* 3. Fall back to Filament::getTenant()
|
|
*/
|
|
private function resolveTenantWithRecord(?Model $record = null): ?Tenant
|
|
{
|
|
// If a record is passed directly (from closure parameter), check if it's a Tenant
|
|
if ($record instanceof Tenant) {
|
|
return $record;
|
|
}
|
|
|
|
// If a record is set from forTableAction, try to resolve it
|
|
if ($this->record !== null) {
|
|
$resolved = $this->record instanceof Closure
|
|
? ($this->record)()
|
|
: $this->record;
|
|
|
|
if ($resolved instanceof Tenant) {
|
|
return $resolved;
|
|
}
|
|
}
|
|
|
|
// Default: use Filament's current tenant
|
|
return Filament::getTenant();
|
|
}
|
|
}
|