527 lines
15 KiB
PHP
527 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Auth;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\RoleCapabilityMap;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use LogicException;
|
|
|
|
class UiEnforcement
|
|
{
|
|
private const TENANT_RESOLVER_FILAMENT = 'filament';
|
|
|
|
private const TENANT_RESOLVER_RECORD = 'record';
|
|
|
|
private const TENANT_RESOLVER_CUSTOM = 'custom';
|
|
|
|
private const BULK_PREFLIGHT_CAPABILITY = 'capability';
|
|
|
|
private const BULK_PREFLIGHT_TENANT_MEMBERSHIP = 'tenant_membership';
|
|
|
|
private const BULK_PREFLIGHT_CUSTOM = 'custom';
|
|
|
|
private bool $preserveVisibility = false;
|
|
|
|
private ?\Closure $businessVisible = null;
|
|
|
|
private ?\Closure $businessHidden = null;
|
|
|
|
private string $tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
|
|
|
private ?\Closure $customTenantResolver = null;
|
|
|
|
private string $bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
|
|
|
/**
|
|
* @var \Closure(Collection<int, Model>): 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<int, Model> $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<int, Model> $records */
|
|
$records = collect($action->getSelectedRecords());
|
|
|
|
return $this->bulkIsDisabled($records);
|
|
});
|
|
|
|
$action->tooltip(function () use ($action): ?string {
|
|
/** @var Collection<int, Model> $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<int, Model> $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<int, Model> $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<int, Model> $records
|
|
* @return array<int>
|
|
*/
|
|
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<int> $tenantIds
|
|
* @return array<int>
|
|
*/
|
|
private function membershipTenantIds(User $user, array $tenantIds): array
|
|
{
|
|
/** @var array<int> $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<int> $tenantIds
|
|
* @return array<int>
|
|
*/
|
|
private function capabilityTenantIds(User $user, array $tenantIds): array
|
|
{
|
|
$roles = RoleCapabilityMap::rolesWithCapability($this->capability);
|
|
|
|
if ($roles === []) {
|
|
return [];
|
|
}
|
|
|
|
/** @var array<int> $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));
|
|
}
|
|
}
|