TenantAtlas/apps/platform/app/Support/Rbac/UiEnforcement.php
ahmido 8cffdbdb2c feat: governance inbox final operator workflow (spec 346) (#418)
Implemented the final operator workflow for the Governance Inbox. This includes refactoring the inbox page, updating finding resources, adding UI enforcement policies, updating related blade views, and adding comprehensive tests for operator workflow and scope contracts.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #418
2026-06-02 14:58:39 +00:00

719 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
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 bool $preserveExistingDisabled = 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 managed-environment access 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;
}
/**
* Preserve the action's existing disabled logic.
*
* Use this when the action is disabled for business reasons (e.g. operation
* state) and you still want UiEnforcement to add capability gating on top.
*
* @return $this
*/
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = 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;
}
$existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null) use ($existingDisabled) {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
if ($this->isBulk && $this->action instanceof BulkAction) {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
/** @var Collection<int, Model> $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<int, Model> $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;
});
}
/**
* Attempt to retrieve the existing disabled condition from the action.
*
* Filament stores this as the protected property `$isDisabled` (bool|Closure)
* on actions via the CanBeDisabled concern.
*/
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
/**
* Evaluate an existing bool|Closure disabled condition.
*
* This is a best-effort evaluator for business disabled closures.
* If the closure cannot be evaluated safely, we fail closed (return true).
*/
private function evaluateDisabledCondition(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 true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
/**
* 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<int, Model> $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<int, Model> $records
* @return array<int, int>
*/
private function resolveTenantIdsFromRecords(Collection $records): array
{
$tenantIds = [];
foreach ($records as $record) {
if ($record instanceof ManagedEnvironment) {
$tenantIds[] = (int) $record->getKey();
continue;
}
if ($record instanceof Model) {
$tenantId = $record->getAttribute('managed_environment_id');
if ($tenantId !== null) {
$tenantIds[] = (int) $tenantId;
continue;
}
if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
$relatedTenant = $record->getRelation('tenant');
if ($relatedTenant instanceof ManagedEnvironment) {
$tenantIds[] = (int) $relatedTenant->getKey();
continue;
}
}
}
}
if ($tenantIds === []) {
$tenant = Filament::getTenant();
if ($tenant instanceof ManagedEnvironment) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
private function makeTenantStub(int $tenantId): ManagedEnvironment
{
$tenant = new ManagedEnvironment;
$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 ManagedEnvironment
$tenant = $this->resolveTenantWithRecord($record);
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
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 ManagedEnvironment, use it
* 2. If $this->record is set (for forTableAction), resolve it
* 3. Fall back to Filament::getTenant()
*/
private function resolveTenantWithRecord(?Model $record = null): ?ManagedEnvironment
{
// If a record is passed directly (from closure parameter), check if it's a ManagedEnvironment
if ($record instanceof ManagedEnvironment) {
return $record;
}
$recordTenant = $this->resolveTenantFromOwnedRecord($record);
if ($recordTenant instanceof ManagedEnvironment) {
return $recordTenant;
}
if ($this->action instanceof Action) {
$actionRecord = $this->action->getRecord(withDefault: false);
if ($actionRecord instanceof ManagedEnvironment) {
return $actionRecord;
}
$actionRecordTenant = $this->resolveTenantFromOwnedRecord($actionRecord);
if ($actionRecordTenant instanceof ManagedEnvironment) {
return $actionRecordTenant;
}
}
// 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 ManagedEnvironment) {
return $resolved;
}
$resolvedTenant = $this->resolveTenantFromOwnedRecord($resolved);
if ($resolvedTenant instanceof ManagedEnvironment) {
return $resolvedTenant;
}
}
// Default: use Filament's current tenant
return Filament::getTenant();
}
private function resolveTenantFromOwnedRecord(mixed $record): ?ManagedEnvironment
{
if (! $record instanceof Model) {
return null;
}
if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
$relatedTenant = $record->getRelation('tenant');
if ($relatedTenant instanceof ManagedEnvironment) {
return $relatedTenant;
}
}
$tenantId = $record->getAttribute('managed_environment_id');
if (! is_numeric($tenantId)) {
return null;
}
return ManagedEnvironment::query()
->withTrashed()
->find((int) $tenantId);
}
}