): bool|null */ private ?\Closure $bulkPreflight = null; public function __construct(private string $capability) { } public static function for(string $capability): self { return new self($capability); } public function preserveVisibility(): self { if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); } $this->preserveVisibility = true; return $this; } public function andVisibleWhen(callable $businessVisible): self { $this->businessVisible = \Closure::fromCallable($businessVisible); return $this; } public function andHiddenWhen(callable $businessHidden): self { $this->businessHidden = \Closure::fromCallable($businessHidden); return $this; } public function tenantFromFilament(): self { $this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT; $this->customTenantResolver = null; return $this; } public function tenantFromRecord(): self { if ($this->preserveVisibility) { throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); } $this->tenantResolverMode = self::TENANT_RESOLVER_RECORD; $this->customTenantResolver = null; return $this; } public function tenantFrom(callable $resolver): self { if ($this->preserveVisibility) { throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); } $this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM; $this->customTenantResolver = \Closure::fromCallable($resolver); return $this; } /** * Custom bulk authorization preflight for selection. * * Signature: fn (Collection $records): bool */ public function preflightSelection(callable $preflight): self { $this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM; $this->bulkPreflight = \Closure::fromCallable($preflight); return $this; } public function preflightByTenantMembership(): self { $this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP; $this->bulkPreflight = null; return $this; } public function preflightByCapability(): self { $this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY; $this->bulkPreflight = null; return $this; } public function apply(Action $action): Action { $this->assertMixedVisibilityConfigIsValid(); if (! $this->preserveVisibility) { $this->applyVisibility($action); } if ($action->isBulk()) { $action->disabled(function () use ($action): bool { /** @var Collection $records */ $records = collect($action->getSelectedRecords()); return $this->bulkIsDisabled($records); }); $action->tooltip(function () use ($action): ?string { /** @var Collection $records */ $records = collect($action->getSelectedRecords()); return $this->bulkDisabledTooltip($records); }); } else { $action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record)); $action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record)); } return $action; } public function isAllowed(?Model $record = null): bool { return ! $this->isDisabled($record); } public function authorizeOrAbort(?Model $record = null): void { $user = auth()->user(); abort_unless($user instanceof User, 403); $tenant = $this->resolveTenant($record); if (! ($tenant instanceof Tenant)) { abort(404); } abort_unless($this->isMemberOfTenant($user, $tenant), 404); abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 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. * * @param Collection $records */ public function authorizeBulkSelectionOrAbort(Collection $records): void { $user = auth()->user(); abort_unless($user instanceof User, 403); $tenantIds = $this->resolveTenantIdsForRecords($records); if ($tenantIds === []) { abort(403); } $membershipTenantIds = $this->membershipTenantIds($user, $tenantIds); if (count($membershipTenantIds) !== count($tenantIds)) { abort(404); } $allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds); if (count($allowedTenantIds) !== count($tenantIds)) { abort(403); } } /** * Public helper for evaluating bulk selection authorization decisions. * * @param Collection $records */ public function bulkSelectionIsAuthorized(User $user, Collection $records): bool { return $this->bulkSelectionIsAuthorizedInternal($user, $records); } private function applyVisibility(Action $action): void { $canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT); $businessVisible = $this->businessVisible; $businessHidden = $this->businessHidden; if ($businessVisible instanceof \Closure) { $action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool { if (! (bool) $action->evaluate($businessVisible)) { return false; } if (! $canApplyMemberVisibility) { return true; } $record = $action->getRecord(); return $this->isMember($record instanceof Model ? $record : null); }); } if ($businessHidden instanceof \Closure) { $action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool { if ($canApplyMemberVisibility) { $record = $action->getRecord(); if (! $this->isMember($record instanceof Model ? $record : null)) { return true; } } return (bool) $action->evaluate($businessHidden); }); return; } if (! $canApplyMemberVisibility) { return; } if (! ($businessVisible instanceof \Closure)) { $action->hidden(function () use ($action): bool { $record = $action->getRecord(); return ! $this->isMember($record instanceof Model ? $record : null); }); } } private function assertMixedVisibilityConfigIsValid(): void { if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) { throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().'); } if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); } } private function isDisabled(?Model $record = null): bool { $user = auth()->user(); if (! ($user instanceof User)) { return true; } $tenant = $this->resolveTenant($record); if (! ($tenant instanceof Tenant)) { return true; } if (! $this->isMemberOfTenant($user, $tenant)) { return true; } return ! Gate::forUser($user)->allows($this->capability, $tenant); } private function disabledTooltip(?Model $record = null): ?string { $user = auth()->user(); if (! ($user instanceof User)) { return null; } $tenant = $this->resolveTenant($record); if (! ($tenant instanceof Tenant)) { return null; } if (! $this->isMemberOfTenant($user, $tenant)) { return null; } if (Gate::forUser($user)->allows($this->capability, $tenant)) { return null; } return UiTooltips::insufficientPermission(); } private function bulkIsDisabled(Collection $records): bool { $user = auth()->user(); if (! ($user instanceof User)) { return true; } return ! $this->bulkSelectionIsAuthorizedInternal($user, $records); } private function bulkDisabledTooltip(Collection $records): ?string { $user = auth()->user(); if (! ($user instanceof User)) { return null; } if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) { return null; } return UiTooltips::insufficientPermission(); } private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool { if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) { return (bool) ($this->bulkPreflight)($records); } $tenantIds = $this->resolveTenantIdsForRecords($records); if ($tenantIds === []) { return false; } return match ($this->bulkPreflightMode) { self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds), self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds), default => false, }; } /** * @param Collection $records * @return array */ private function resolveTenantIdsForRecords(Collection $records): array { if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) { $tenant = Filament::getTenant(); return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : []; } if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) { $ids = $records ->filter(fn (Model $record): bool => $record instanceof Tenant) ->map(fn (Tenant $tenant): int => (int) $tenant->getKey()) ->all(); return array_values(array_unique($ids)); } if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) { $ids = []; foreach ($records as $record) { if (! ($record instanceof Model)) { continue; } $resolved = ($this->customTenantResolver)($record); if ($resolved instanceof Tenant) { $ids[] = (int) $resolved->getKey(); continue; } if (is_int($resolved)) { $ids[] = $resolved; } } return array_values(array_unique($ids)); } return []; } private function isMember(?Model $record = null): bool { $user = auth()->user(); if (! ($user instanceof User)) { return false; } $tenant = $this->resolveTenant($record); if (! ($tenant instanceof Tenant)) { return false; } return $this->isMemberOfTenant($user, $tenant); } private function isMemberOfTenant(User $user, Tenant $tenant): bool { return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant); } private function resolveTenant(?Model $record = null): ?Tenant { return match ($this->tenantResolverMode) { self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null, self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null, self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record), default => null, }; } private function resolveTenantViaCustomResolver(?Model $record): ?Tenant { if (! ($this->customTenantResolver instanceof \Closure)) { return null; } if (! ($record instanceof Model)) { return null; } $resolved = ($this->customTenantResolver)($record); if ($resolved instanceof Tenant) { return $resolved; } return null; } /** * @param array $tenantIds * @return array */ private function membershipTenantIds(User $user, array $tenantIds): array { /** @var array $ids */ $ids = DB::table('tenant_memberships') ->where('user_id', (int) $user->getKey()) ->whereIn('tenant_id', $tenantIds) ->pluck('tenant_id') ->map(fn ($id): int => (int) $id) ->all(); return array_values(array_unique($ids)); } /** * @param array $tenantIds * @return array */ private function capabilityTenantIds(User $user, array $tenantIds): array { $roles = RoleCapabilityMap::rolesWithCapability($this->capability); if ($roles === []) { return []; } /** @var array $ids */ $ids = DB::table('tenant_memberships') ->where('user_id', (int) $user->getKey()) ->whereIn('tenant_id', $tenantIds) ->whereIn('role', $roles) ->pluck('tenant_id') ->map(fn ($id): int => (int) $id) ->all(); return array_values(array_unique($ids)); } }