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
231 lines
6.0 KiB
PHP
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;
|
|
}
|
|
}
|