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; } /** * Evaluate whether a bulk selection is authorized (all-or-nothing). * * - If any selected tenant is not a membership: false. * - If all are memberships but any lacks capability: false. */ public function bulkSelectionIsAuthorized(User $user, Collection $records): bool { $tenantIds = $this->resolveTenantIdsFromRecords($records); if ($tenantIds === []) { return false; } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); $resolver->primeMemberships($user, $tenantIds); foreach ($tenantIds as $tenantId) { $tenant = $this->makeTenantStub($tenantId); if (! $resolver->isMember($user, $tenant)) { return false; } if ($this->capability !== null && ! $resolver->can($user, $tenant, $this->capability)) { return false; } } return true; } /** * 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 ?? AuthUiTooltips::insufficientPermission(); $this->action->disabled(function (?Model $record = null) { if ($this->isBulk && $this->action instanceof BulkAction) { $user = auth()->user(); if (! $user instanceof User) { return true; } /** @var Collection $records */ $records = collect($this->action->getSelectedRecords()); return ! $this->bulkSelectionIsAuthorized($user, $records); } $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) { if ($this->isBulk && $this->action instanceof BulkAction) { $user = auth()->user(); if (! $user instanceof User) { return $tooltip; } /** @var Collection $records */ $records = collect($this->action->getSelectedRecords()); if (! $this->bulkSelectionIsAuthorized($user, $records)) { return $tooltip; } return null; } $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 { if ($this->isBulk && $this->action instanceof BulkAction) { $user = auth()->user(); if (! $user instanceof User) { abort(403); } /** @var Collection $records */ $records = collect($this->action->getSelectedRecords()); $this->authorizeBulkSelectionOrAbort($user, $records); return; } $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); } }); } /** * Server-side enforcement for bulk selections. * * - If any selected tenant is not a membership: 404 (deny-as-not-found). * - If all are memberships but any lacks capability: 403. */ private function authorizeBulkSelectionOrAbort(User $user, Collection $records): void { $tenantIds = $this->resolveTenantIdsFromRecords($records); if ($tenantIds === []) { abort(403); } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); $resolver->primeMemberships($user, $tenantIds); foreach ($tenantIds as $tenantId) { $tenant = $this->makeTenantStub($tenantId); if (! $resolver->isMember($user, $tenant)) { abort(404); } } if ($this->capability === null) { return; } foreach ($tenantIds as $tenantId) { $tenant = $this->makeTenantStub($tenantId); if (! $resolver->can($user, $tenant, $this->capability)) { abort(403); } } } /** * @param Collection $records * @return array */ private function resolveTenantIdsFromRecords(Collection $records): array { $tenantIds = []; foreach ($records as $record) { if ($record instanceof Tenant) { $tenantIds[] = (int) $record->getKey(); continue; } if ($record instanceof Model) { $tenantId = $record->getAttribute('tenant_id'); if ($tenantId !== null) { $tenantIds[] = (int) $tenantId; continue; } if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) { $relatedTenant = $record->getRelation('tenant'); if ($relatedTenant instanceof Tenant) { $tenantIds[] = (int) $relatedTenant->getKey(); continue; } } } } if ($tenantIds === []) { $tenant = Filament::getTenant(); if ($tenant instanceof Tenant) { $tenantIds[] = (int) $tenant->getKey(); } } return array_values(array_unique($tenantIds)); } private function makeTenantStub(int $tenantId): Tenant { $tenant = new Tenant; $tenant->forceFill(['id' => $tenantId]); $tenant->exists = true; return $tenant; } /** * 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 has an eagerly-loaded `tenant` relation, prefer it. // This avoids relying on Filament::getTenant() for list pages that are not tenant-scoped. if ($record instanceof Model && method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) { $relatedTenant = $record->getRelation('tenant'); if ($relatedTenant instanceof Tenant) { return $relatedTenant; } } // 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(); } }