TenantAtlas/app/Support/Rbac/WorkspaceUiEnforcement.php
ahmido 5f9e6fb04a feat: workspace-first managed tenants + RBAC membership UI fixes (072) (#87)
Implements spec 072 (workspace-first managed tenants enforcement) and follow-up RBAC fixes.

Highlights:
- Workspace-scoped managed tenants landing and enforcement for tenant routes.
- Workspace membership management UI fixed to use workspace capabilities.
- Membership tables now show user email + domain for clearer identification.

Tests:
- Targeted Pest tests for routing/enforcement and RBAC UI enforcement.
- Pint ran on dirty files.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #87
2026-02-02 23:54:22 +00:00

231 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use Closure;
use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;
use Throwable;
/**
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
*
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
* + WorkspaceCapabilityResolver.
*
* Rules:
* - Non-member → hidden UI + 404 server-side
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
* - Member with capability → enabled
*/
final class WorkspaceUiEnforcement
{
private Action $action;
private bool $requireMembership = true;
private ?string $capability = null;
private bool $isDestructive = false;
private ?string $customTooltip = null;
private Model|Closure|null $record = null;
private function __construct(Action $action)
{
$this->action = $action;
}
/**
* Create enforcement for a table action.
*
* @param Action $action The Filament action to wrap
* @param Model|Closure $record The owner record or a closure that returns it
*/
public static function forTableAction(Action $action, Model|Closure $record): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
return $this;
}
/**
* @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;
}
public function destructive(): self
{
$this->isDestructive = true;
return $this;
}
public function tooltip(string $message): self
{
$this->customTooltip = $message;
return $this;
}
public function apply(): Action
{
$this->applyVisibility();
$this->applyDisabledState();
$this->applyDestructiveConfirmation();
$this->applyServerSideGuard();
return $this->action;
}
private function applyVisibility(): void
{
if (! $this->requireMembership) {
return;
}
$this->action->visible(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
return $context->isMember;
});
}
private function applyDisabledState(): void
{
if ($this->capability === null) {
return;
}
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
return true;
}
return ! $context->hasCapability;
});
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
$context = $this->resolveContextWithRecord($record);
if ($context->isMember && ! $context->hasCapability) {
return $tooltip;
}
return null;
});
}
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);
}
private function applyServerSideGuard(): void
{
$this->action->before(function (?Model $record = null): void {
$context = $this->resolveContextWithRecord($record);
if ($context->shouldDenyAsNotFound()) {
abort(404);
}
if ($context->shouldDenyAsForbidden()) {
abort(403);
}
});
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{
$user = auth()->user();
$workspace = $this->resolveWorkspaceWithRecord($record);
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return new WorkspaceAccessContext(
user: null,
workspace: null,
isMember: false,
hasCapability: false,
);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
$isMember = $resolver->isMember($user, $workspace);
$hasCapability = true;
if ($this->capability !== null && $isMember) {
$hasCapability = $resolver->can($user, $workspace, $this->capability);
}
return new WorkspaceAccessContext(
user: $user,
workspace: $workspace,
isMember: $isMember,
hasCapability: $hasCapability,
);
}
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
{
if ($record instanceof Workspace) {
return $record;
}
if ($this->record !== null) {
try {
$resolved = $this->record instanceof Closure
? ($this->record)()
: $this->record;
if ($resolved instanceof Workspace) {
return $resolved;
}
} catch (Throwable) {
return null;
}
}
return null;
}
}