From 538c6ea268504cce04c849f79728837d11950c12 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 30 Jan 2026 17:39:11 +0100 Subject: [PATCH] feat(066): centralize RBAC UI enforcement with UiEnforcement --- .gitignore | 4 + app/Filament/Pages/DriftLanding.php | 7 +- app/Filament/Pages/Tenancy/RegisterTenant.php | 11 +- .../Resources/BackupScheduleResource.php | 888 +++++++------ app/Filament/Resources/BackupSetResource.php | 633 ++++----- .../BackupItemsRelationManager.php | 386 +++--- .../Pages/ListEntraGroups.php | 214 ++-- .../Pages/ListEntraGroupSyncRuns.php | 124 +- app/Filament/Resources/FindingResource.php | 136 +- .../FindingResource/Pages/ListFindings.php | 138 +- .../Resources/InventoryItemResource.php | 25 +- .../Pages/ListInventoryItems.php | 400 +++--- .../Resources/InventorySyncRunResource.php | 22 +- app/Filament/Resources/PolicyResource.php | 1026 +++++++-------- .../PolicyResource/Pages/ListPolicies.php | 143 +-- .../VersionsRelationManager.php | 171 ++- .../Resources/PolicyVersionResource.php | 1135 ++++++++--------- .../Resources/ProviderConnectionResource.php | 918 +++++++------ .../Pages/EditProviderConnection.php | 1023 ++++++++------- .../Pages/ListProviderConnections.php | 14 +- app/Filament/Resources/RestoreRunResource.php | 948 +++++++------- .../Pages/CreateRestoreRun.php | 19 +- app/Filament/Resources/TenantResource.php | 139 +- .../TenantResource/Pages/EditTenant.php | 55 +- .../TenantMembershipsRelationManager.php | 303 +++-- app/Policies/FindingPolicy.php | 7 +- app/Services/Auth/RoleCapabilityMap.php | 6 + app/Support/Auth/Capabilities.php | 6 + app/Support/Rbac/TenantAccessContext.php | 48 + app/Support/Rbac/UiEnforcement.php | 414 ++++++ app/Support/Rbac/UiTooltips.php | 33 + 31 files changed, 4955 insertions(+), 4441 deletions(-) create mode 100644 app/Support/Rbac/TenantAccessContext.php create mode 100644 app/Support/Rbac/UiEnforcement.php create mode 100644 app/Support/Rbac/UiTooltips.php diff --git a/.gitignore b/.gitignore index 766ffe9..50eef79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.log .DS_Store .env +.env.* .env.backup .env.production .phpactor.json @@ -21,7 +22,10 @@ coverage/ /public/storage /storage/*.key /storage/pail +/storage/framework +/storage/logs /vendor +/bootstrap/cache Homestead.json Homestead.yaml Thumbs.db diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 0669388..c225bb5 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -10,6 +10,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Drift\DriftRunSelector; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; @@ -21,7 +22,6 @@ use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Pages\Page; -use Illuminate\Support\Facades\Gate; use UnitEnum; class DriftLanding extends Page @@ -175,7 +175,10 @@ public function mount(): void } } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) { $this->state = 'blocked'; $this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.'; diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index 06def3d..5940f23 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -4,13 +4,13 @@ use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Support\Auth\Capabilities; use Filament\Forms; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Schemas\Schema; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; class RegisterTenant extends BaseRegisterTenant { @@ -33,8 +33,11 @@ public static function canView(): bool return false; } + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { - if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) { + if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { return true; } } @@ -88,7 +91,9 @@ public function form(Schema $schema): Schema */ protected function handleRegistration(array $data): Model { - abort_unless(static::canView(), 403); + if (! static::canView()) { + abort(403); + } $tenant = Tenant::create($data); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 149464c..6ce9c03 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -11,6 +11,7 @@ use App\Models\Tenant; use App\Models\User; use App\Rules\SupportedPolicyTypesRule; +use App\Services\Auth\CapabilityResolver; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\Intune\AuditLogger; @@ -23,6 +24,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Carbon\CarbonImmutable; use DateTimeZone; @@ -50,7 +52,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -65,19 +66,32 @@ public static function canViewAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } @@ -92,32 +106,64 @@ public static function canCreate(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canEdit(Model $record): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canDelete(Model $record): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canDeleteAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function form(Schema $schema): Schema @@ -315,329 +361,51 @@ public static function table(Table $table): Table ]) ->actions([ ActionGroup::make([ - Action::make('runNow') - ->label('Run now') - ->icon('heroicon-o-play') - ->color('success') - ->visible(function (): bool { - $tenant = Tenant::current(); + UiEnforcement::forAction( + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - $user = auth()->user(); - $userId = auth()->id(); - $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.run_now', - inputs: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Run already queued') - ->body('This schedule already has a queued or running backup.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); + return; } - } - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Run already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); - - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'success', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'run_now', - ], - ], - ); - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $operationRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - }), - Action::make('retry') - ->label('Retry') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - $user = auth()->user(); - $userId = auth()->id(); - $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.retry', - inputs: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Retry already queued') - ->body('This schedule already has a queued or running retry.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Retry already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); - - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'success', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'retry', - ], - ], - ); - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $operationRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - }), - EditAction::make() - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), - DeleteAction::make() - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), - ])->icon('heroicon-o-ellipsis-vertical'), - ]) - ->bulkActions([ - BulkActionGroup::make([ - BulkAction::make('bulk_run_now') - ->label('Run now') - ->icon('heroicon-o-play') - ->color('success') - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - if ($records->isEmpty()) { - return; - } - - $tenant = Tenant::current(); - $userId = auth()->id(); - $user = $userId ? User::query()->find($userId) : null; - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - $bulkRun = null; - - $createdRunIds = []; - - /** @var BackupSchedule $record */ - foreach ($records as $record) { + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, type: 'backup_schedule.run_now', inputs: [ 'backup_schedule_id' => (int) $record->getKey(), ], - initiator: $user + initiator: $userModel ); if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; + Notification::make() + ->title('Run already queued') + ->body('This schedule already has a queued or running backup.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + + return; } $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); @@ -660,6 +428,17 @@ public static function table(Table $table): Table } if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Run already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + $operationRunService->updateRun( $operationRun, status: 'completed', @@ -675,11 +454,9 @@ public static function table(Table $table): Table ], ); - continue; + return; } - $createdRunIds[] = (int) $run->id; - $operationRun->update([ 'context' => array_merge($operationRun->context ?? [], [ 'backup_schedule_id' => (int) $record->getKey(), @@ -698,83 +475,72 @@ public static function table(Table $table): Table 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'bulk_run_now', + 'trigger' => 'run_now', ], ], ); $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }, emitQueuedNotification: false); - } + }); - $notification = Notification::make() - ->title('Runs dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); - - if (count($createdRunIds) === 0) { - $notification->warning(); - } else { - $notification->success(); - } - - if ($user instanceof User) { - $notification->actions([ - Action::make('view_runs') - ->label('View in Operations') - ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); - } - - $notification->send(); - - if (count($createdRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - } - }), - BulkAction::make('bulk_retry') - ->label('Retry') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $tenant = Tenant::current(); + OperationUxPresenter::queuedToast((string) $operationRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forAction( + Action::make('retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); + return; + } - if ($records->isEmpty()) { - return; - } + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - $tenant = Tenant::current(); - $userId = auth()->id(); - $user = $userId ? User::query()->find($userId) : null; - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - $bulkRun = null; - - $createdRunIds = []; - - /** @var BackupSchedule $record */ - foreach ($records as $record) { + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, type: 'backup_schedule.retry', inputs: [ 'backup_schedule_id' => (int) $record->getKey(), ], - initiator: $user + initiator: $userModel ); if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; + Notification::make() + ->title('Retry already queued') + ->body('This schedule already has a queued or running retry.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + + return; } $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); @@ -797,6 +563,17 @@ public static function table(Table $table): Table } if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Retry already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + $operationRunService->updateRun( $operationRun, status: 'completed', @@ -812,11 +589,9 @@ public static function table(Table $table): Table ], ); - continue; + return; } - $createdRunIds[] = (int) $run->id; - $operationRun->update([ 'context' => array_merge($operationRun->context ?? [], [ 'backup_schedule_id' => (int) $record->getKey(), @@ -835,47 +610,328 @@ public static function table(Table $table): Table 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'bulk_retry', + 'trigger' => 'retry', ], ], ); $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }, emitQueuedNotification: false); - } + }); - $notification = Notification::make() - ->title('Retries dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); - - if (count($createdRunIds) === 0) { - $notification->warning(); - } else { - $notification->success(); - } - - if ($user instanceof User) { - $notification->actions([ - Action::make('view_runs') - ->label('View in Operations') - ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); - } - - $notification->send(); - - if (count($createdRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - } - }), - DeleteBulkAction::make('bulk_delete') - ->visible(function (): bool { - $tenant = Tenant::current(); + OperationUxPresenter::queuedToast((string) $operationRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forAction( + EditAction::make() + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), + UiEnforcement::forAction( + DeleteAction::make() + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + UiEnforcement::forBulkAction( + BulkAction::make('bulk_run_now') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); + + return; + } + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $bulkRun = null; + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.run_now', + inputs: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { + continue; + } + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + failures: [ + [ + 'code' => 'SCHEDULE_CONFLICT', + 'message' => 'Unable to queue a unique backup schedule run.', + ], + ], + ); + + continue; + } + + $createdRunIds[] = (int) $run->id; + + $operationRun->update([ + 'context' => array_merge($operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $record->getKey(), + 'backup_schedule_run_id' => (int) $run->getKey(), + ]), + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_run_now', + ], + ], + ); + + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + }, emitQueuedNotification: false); + } + + $notification = Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $notification->actions([ + Action::make('view_runs') + ->label('View in Operations') + ->url(OperationRunLinks::index($tenant)), + ])->sendToDatabase($user); + } + + $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forBulkAction( + BulkAction::make('bulk_retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); + + return; + } + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $bulkRun = null; + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.retry', + inputs: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { + continue; + } + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + failures: [ + [ + 'code' => 'SCHEDULE_CONFLICT', + 'message' => 'Unable to queue a unique backup schedule retry run.', + ], + ], + ); + + continue; + } + + $createdRunIds[] = (int) $run->id; + + $operationRun->update([ + 'context' => array_merge($operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $record->getKey(), + 'backup_schedule_run_id' => (int) $run->getKey(), + ]), + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_retry', + ], + ], + ); + + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + }, emitQueuedNotification: false); + } + + $notification = Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $notification->actions([ + Action::make('view_runs') + ->label('View in Operations') + ->url(OperationRunLinks::index($tenant)), + ])->sendToDatabase($user); + } + + $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forBulkAction( + DeleteBulkAction::make('bulk_delete') + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index ecb62ff..e92e30c 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -10,6 +10,7 @@ use App\Models\BackupSet; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use App\Services\OperationRunService; @@ -19,11 +20,13 @@ use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -34,7 +37,6 @@ use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class BackupSetResource extends Resource @@ -47,8 +49,18 @@ class BackupSetResource extends Resource public static function canCreate(): bool { - return ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_SYNC, $tenant); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); } public static function form(Schema $schema): Schema @@ -90,353 +102,356 @@ public static function table(Table $table): Table ->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), ActionGroup::make([ - Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->visible(fn (BackupSet $record): bool => $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - $tenant = Tenant::current(); + UiEnforcement::forAction( + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->restore(); + $record->items()->withTrashed()->restore(); - $record->restore(); - $record->items()->withTrashed()->restore(); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.restored', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.restored', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } - - Notification::make() - ->title('Backup set restored') - ->success() - ->send(); - }), - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->visible(fn (BackupSet $record): bool => ! $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->delete(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.deleted', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } - - Notification::make() - ->title('Backup set archived') - ->success() - ->send(); - }), - Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->visible(fn (BackupSet $record): bool => $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); - - if ($record->restoreRuns()->withTrashed()->exists()) { Notification::make() - ->title('Cannot force delete backup set') - ->body('Backup sets referenced by restore runs cannot be removed.') - ->danger() + ->title('Backup set restored') + ->success() ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => ! $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); - return; - } + $record->delete(); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.force_deleted', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.deleted', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } - $record->items()->withTrashed()->forceDelete(); - $record->forceDelete(); + Notification::make() + ->title('Backup set archived') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); - Notification::make() - ->title('Backup set permanently deleted') - ->success() - ->send(); - }), + if ($record->restoreRuns()->withTrashed()->exists()) { + Notification::make() + ->title('Cannot force delete backup set') + ->body('Backup sets referenced by restore runs cannot be removed.') + ->danger() + ->send(); + + return; + } + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.force_deleted', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } + + $record->items()->withTrashed()->forceDelete(); + $record->forceDelete(); + + Notification::make() + ->title('Backup set permanently deleted') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('bulk_delete') - ->label('Archive Backup Sets') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - $isOnlyTrashed = in_array($value, [0, '0', false], true); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return $isOnlyTrashed; - }) - ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 10) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + return $isOnlyTrashed; + }) + ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - if (! $tenant instanceof Tenant) { - return; - } + if (! $tenant instanceof Tenant) { + return; + } - abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; - $initiator = $user instanceof User ? $user : null; + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); + OperationUxPresenter::queuedToast('backup_set.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - OperationUxPresenter::queuedToast('backup_set.delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Backup Sets') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - BulkAction::make('bulk_restore') - ->label('Restore Backup Sets') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + $isOnlyTrashed = in_array($value, [0, '0', false], true); - $isOnlyTrashed = in_array($value, [0, '0', false], true); + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") + ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - return ! $isOnlyTrashed; - }) - ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") - ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + if (! $tenant instanceof Tenant) { + return; + } - if (! $tenant instanceof Tenant) { - return; - } + $initiator = $user instanceof User ? $user : null; - abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - $initiator = $user instanceof User ? $user : null; + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + OperationUxPresenter::queuedToast('backup_set.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetRestoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); + UiEnforcement::forBulkAction( + BulkAction::make('bulk_force_delete') + ->label('Force Delete Backup Sets') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - OperationUxPresenter::queuedToast('backup_set.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + $isOnlyTrashed = in_array($value, [0, '0', false], true); - BulkAction::make('bulk_force_delete') - ->label('Force Delete Backup Sets') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") + ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - $isOnlyTrashed = in_array($value, [0, '0', false], true); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - return ! $isOnlyTrashed; - }) - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") - ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 10) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + if (! $tenant instanceof Tenant) { + return; + } - return []; - }) - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + $initiator = $user instanceof User ? $user : null; - if (! $tenant instanceof Tenant) { - return; - } + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $initiator = $user instanceof User ? $user : null; + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetForceDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('backup_set.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + OperationUxPresenter::queuedToast('backup_set.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 05b7486..75e0d25 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -16,6 +16,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -24,7 +25,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Gate; class BackupItemsRelationManager extends RelationManager { @@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void public function table(Table $table): Table { + $refreshTable = Actions\Action::make('refreshTable') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $this->resetTable(); + }); + + $addPolicies = Actions\Action::make('addPolicies') + ->label('Add Policies') + ->icon('heroicon-o-plus') + ->tooltip('You do not have permission to add policies.') + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { + $backupSet = $this->getOwnerRecord(); + + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); + }); + + UiEnforcement::forAction($addPolicies) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to add policies.') + ->apply(); + + $removeItem = Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record): void { + $backupSet = $this->getOwnerRecord(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } + + if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { + abort(404); + } + + $backupItemIds = [(int) $record->getKey()]; + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'backup_set.remove_policies', + inputs: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'backup_item_ids' => $backupItemIds, + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Removal already queued') + ->body('A matching remove operation is already queued or running.') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }); + + UiEnforcement::forAction($removeItem) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to remove policies.') + ->apply(); + + $bulkRemove = Actions\BulkAction::make('bulk_remove') + ->label('Remove selected') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->deselectRecordsAfterCompletion() + ->action(function (Collection $records): void { + if ($records->isEmpty()) { + return; + } + + $backupSet = $this->getOwnerRecord(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } + + if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { + abort(404); + } + + $backupItemIds = $records + ->pluck('id') + ->map(fn (mixed $value): int => (int) $value) + ->filter(fn (int $value): bool => $value > 0) + ->unique() + ->sort() + ->values() + ->all(); + + if ($backupItemIds === []) { + return; + } + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'backup_set.remove_policies', + inputs: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'backup_item_ids' => $backupItemIds, + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Removal already queued') + ->body('A matching remove operation is already queued or running.') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }); + + UiEnforcement::forBulkAction($bulkRemove) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to remove policies.') + ->apply(); + return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->columns([ @@ -125,29 +318,8 @@ public function table(Table $table): Table ]) ->filters([]) ->headerActions([ - Actions\Action::make('refreshTable') - ->label('Refresh') - ->icon('heroicon-o-arrow-path') - ->action(function (): void { - $this->resetTable(); - }), - Actions\Action::make('addPolicies') - ->label('Add Policies') - ->icon('heroicon-o-plus') - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_SYNC, $tenant))) - ->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.') - ->modalHeading('Add Policies') - ->modalSubmitAction(false) - ->modalCancelActionLabel('Close') - ->modalContent(function (): View { - $backupSet = $this->getOwnerRecord(); - - return view('filament.modals.backup-set-policy-picker', [ - 'backupSetId' => $backupSet->getKey(), - ]); - }), + $refreshTable, + $addPolicies, ]) ->actions([ Actions\ActionGroup::make([ @@ -164,174 +336,12 @@ public function table(Table $table): Table }) ->hidden(fn (BackupItem $record) => ! $record->policy_id) ->openUrlInNewTab(true), - Actions\Action::make('remove') - ->label('Remove') - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (BackupItem $record): void { - $backupSet = $this->getOwnerRecord(); - - $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } - - $tenant = $backupSet->tenant ?? Tenant::current(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { - abort(403); - } - - $backupItemIds = [(int) $record->getKey()]; - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'backup_set.remove_policies', - inputs: [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'backup_item_ids' => $backupItemIds, - ], - initiator: $user, - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { - RemovePoliciesFromBackupSetJob::dispatch( - backupSetId: (int) $backupSet->getKey(), - backupItemIds: $backupItemIds, - initiatorUserId: (int) $user->getKey(), - operationRun: $opRun, - ); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), + $removeItem, ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ Actions\BulkActionGroup::make([ - Actions\BulkAction::make('bulk_remove') - ->label('Remove selected') - ->icon('heroicon-o-x-mark') - ->color('danger') - ->requiresConfirmation() - ->deselectRecordsAfterCompletion() - ->action(function (Collection $records): void { - if ($records->isEmpty()) { - return; - } - - $backupSet = $this->getOwnerRecord(); - - $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } - - $tenant = $backupSet->tenant ?? Tenant::current(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { - abort(403); - } - - $backupItemIds = $records - ->pluck('id') - ->map(fn (mixed $value): int => (int) $value) - ->filter(fn (int $value): bool => $value > 0) - ->unique() - ->sort() - ->values() - ->all(); - - if ($backupItemIds === []) { - return; - } - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'backup_set.remove_policies', - inputs: [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'backup_item_ids' => $backupItemIds, - ], - initiator: $user, - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { - RemovePoliciesFromBackupSetJob::dispatch( - backupSetId: (int) $backupSet->getKey(), - backupItemIds: $backupItemIds, - initiatorUserId: (int) $user->getKey(), - operationRun: $opRun, - ); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), + $bulkRemove, ]), ]); } diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index c2bca06..998064e 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -12,10 +12,10 @@ use App\Services\OperationRunService; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListEntraGroups extends ListRecords { @@ -29,121 +29,90 @@ protected function getHeaderActions(): array ->icon('heroicon-o-clock') ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) ->visible(fn (): bool => (bool) Tenant::current()), + UiEnforcement::forAction( + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (): void { + $user = auth()->user(); + $tenant = Tenant::current(); - Action::make('sync_groups') - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $user = auth()->user(); + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; + } - if (! $user instanceof User) { - return false; - } + $selectionKey = EntraGroupSelection::allGroupsV1(); - $tenant = Tenant::current(); + // --- Phase 3: Canonical Operation Run Start --- + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => $selectionKey], + initiator: $user + ); - if (! $tenant) { - return false; - } + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { + Notification::make() + ->title('Group sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - if (! $user->canAccessTenant($tenant)) { - return false; - } + return; + } + // ---------------------------------------------- - return true; - }) - ->disabled(function (): bool { - $user = auth()->user(); + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); - if (! $user instanceof User) { - return true; - } + if ($existing instanceof EntraGroupSyncRun) { + Notification::make() + ->title('Group sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->sendToDatabase($user) + ->send(); - $tenant = Tenant::current(); + return; + } - if (! $tenant instanceof Tenant) { - return true; - } + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + operationRun: $opRun + )); - if (! $user instanceof User) { - return null; - } - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) - ? null - : 'You do not have permission to sync groups.'; - }) - ->action(function (): void { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - $tenant = Tenant::current(); - - if (! $tenant) { - abort(403); - } - - if (! $user->canAccessTenant($tenant)) { - abort(403); - } - - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403); - - $selectionKey = EntraGroupSelection::allGroupsV1(); - - // --- Phase 3: Canonical Operation Run Start --- - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'directory_groups.sync', - inputs: ['selection_key' => $selectionKey], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - // ---------------------------------------------- - - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() + ->title('Group sync started') + ->body('Sync dispatched.') + ->success() ->actions([ Action::make('view_run') ->label('View Run') @@ -151,38 +120,11 @@ protected function getHeaderActions(): array ]) ->sendToDatabase($user) ->send(); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - operationRun: $opRun - )); - - Notification::make() - ->title('Group sync started') - ->body('Sync dispatched.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->sendToDatabase($user) - ->send(); - }), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to sync groups.') + ->apply(), ]; } } diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index 012d578..ff0fecb 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -10,9 +10,10 @@ use App\Notifications\RunStatusChangedNotification; use App\Services\Directory\EntraGroupSelection; use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListEntraGroupSyncRuns extends ListRecords { @@ -21,92 +22,67 @@ class ListEntraGroupSyncRuns extends ListRecords protected function getHeaderActions(): array { return [ - Action::make('sync_groups') - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $user = auth()->user(); + UiEnforcement::forAction( + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (): void { + $user = auth()->user(); + $tenant = Tenant::current(); - if (! $user instanceof User) { - return false; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; + } - $tenant = Tenant::current(); + $selectionKey = EntraGroupSelection::allGroupsV1(); - if (! $tenant) { - return false; - } + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); - if (! $user->canAccessTenant($tenant)) { - return false; - } + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->action(function (): void { - $user = auth()->user(); + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); - if (! $user instanceof User) { - abort(403); - } + return; + } - $tenant = Tenant::current(); + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); - if (! $tenant) { - abort(403); - } - - if (! $user->canAccessTenant($tenant)) { - abort(403); - } - - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403); - - $selectionKey = EntraGroupSelection::allGroupsV1(); - - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); $user->notify(new RunStatusChangedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'run_type' => 'directory_groups', - 'run_id' => (int) $existing->getKey(), - 'status' => $normalizedStatus, + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', ])); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - )); - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $run->getKey(), - 'status' => 'queued', - ])); - }), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index e0d1265..968c6e6 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -12,10 +12,13 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use BackedEnum; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -29,7 +32,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class FindingResource extends Resource @@ -46,19 +48,34 @@ public static function canViewAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return $user->can(Capabilities::TENANT_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + if (! $user->canAccessTenant($tenant)) { + return false; + } + + if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) { return false; } @@ -343,75 +360,62 @@ public static function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('acknowledge_selected') - ->label('Acknowledge selected') - ->icon('heroicon-o-check') - ->color('gray') - ->authorize(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + UiEnforcement::forBulkAction( + BulkAction::make('acknowledge_selected') + ->label('Acknowledge selected') + ->icon('heroicon-o-check') + ->color('gray') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); - if (! $tenant || ! $user instanceof User) { - return false; - } - - $probe = new Finding(['tenant_id' => $tenant->getKey()]); - - return $user->can('update', $probe); - }) - ->authorizeIndividualRecords('update') - ->requiresConfirmation() - ->action(function (Collection $records): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant || ! $user instanceof User) { - return; - } - - $firstRecord = $records->first(); - if ($firstRecord instanceof Finding) { - Gate::authorize('update', $firstRecord); - } - - $acknowledgedCount = 0; - $skippedCount = 0; - - foreach ($records as $record) { - if (! $record instanceof Finding) { - $skippedCount++; - - continue; + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; } - if ((int) $record->tenant_id !== (int) $tenant->getKey()) { - $skippedCount++; + $acknowledgedCount = 0; + $skippedCount = 0; - continue; + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if ($record->status !== Finding::STATUS_NEW) { + $skippedCount++; + + continue; + } + + $record->acknowledge($user); + $acknowledgedCount++; } - if ($record->status !== Finding::STATUS_NEW) { - $skippedCount++; - - continue; + $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; } - $record->acknowledge($user); - $acknowledgedCount++; - } - - $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; - if ($skippedCount > 0) { - $body .= " Skipped {$skippedCount}."; - } - - Notification::make() - ->title('Bulk acknowledge completed') - ->body($body) - ->success() - ->send(); - }) - ->deselectRecordsAfterCompletion(), + Notification::make() + ->title('Bulk acknowledge completed') + ->body($body) + ->success() + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 0322a04..612baa1 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -4,15 +4,15 @@ use App\Filament\Resources\FindingResource; use App\Models\Finding; -use App\Models\Tenant; -use App\Models\User; +use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Gate; class ListFindings extends ListRecords { @@ -21,101 +21,83 @@ class ListFindings extends ListRecords protected function getHeaderActions(): array { return [ - Actions\Action::make('acknowledge_all_matching') - ->label('Acknowledge all matching') - ->icon('heroicon-o-check') - ->color('gray') - ->requiresConfirmation() - ->authorize(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + UiEnforcement::forAction( + Actions\Action::make('acknowledge_all_matching') + ->label('Acknowledge all matching') + ->icon('heroicon-o-check') + ->color('gray') + ->requiresConfirmation() + ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) + ->modalDescription(function (): string { + $count = $this->getAllMatchingCount(); - if (! $tenant || ! $user instanceof User) { - return false; - } + return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; + }) + ->form(function (): array { + $count = $this->getAllMatchingCount(); - $probe = new Finding(['tenant_id' => $tenant->getKey()]); + if ($count <= 100) { + return []; + } - return $user->can('update', $probe); - }) - ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) - ->modalDescription(function (): string { - $count = $this->getAllMatchingCount(); + return [ + TextInput::make('confirmation') + ->label('Type ACKNOWLEDGE to confirm') + ->required() + ->in(['ACKNOWLEDGE']) + ->validationMessages([ + 'in' => 'Please type ACKNOWLEDGE to confirm.', + ]), + ]; + }) + ->action(function (array $data): void { + $query = $this->buildAllMatchingQuery(); + $count = (clone $query)->count(); - return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; - }) - ->form(function (): array { - $count = $this->getAllMatchingCount(); + if ($count === 0) { + Notification::make() + ->title('No matching findings') + ->body('There are no new findings matching the current filters.') + ->warning() + ->send(); - if ($count <= 100) { - return []; - } + return; + } - return [ - TextInput::make('confirmation') - ->label('Type ACKNOWLEDGE to confirm') - ->required() - ->in(['ACKNOWLEDGE']) - ->validationMessages([ - 'in' => 'Please type ACKNOWLEDGE to confirm.', - ]), - ]; - }) - ->action(function (array $data): void { - $tenant = Tenant::current(); - $user = auth()->user(); + $updated = $query->update([ + 'status' => Finding::STATUS_ACKNOWLEDGED, + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => auth()->id(), + ]); - if (! $tenant || ! $user instanceof User) { - return; - } + $this->deselectAllTableRecords(); + $this->resetPage(); - $query = $this->buildAllMatchingQuery(); - $count = (clone $query)->count(); - - if ($count === 0) { Notification::make() - ->title('No matching findings') - ->body('There are no new findings matching the current filters.') - ->warning() + ->title('Bulk acknowledge completed') + ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') + ->success() ->send(); - - return; - } - - $firstRecord = (clone $query)->first(); - if ($firstRecord instanceof Finding) { - Gate::authorize('update', $firstRecord); - } - - $updated = $query->update([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), - ]); - - $this->deselectAllTableRecords(); - $this->resetPage(); - - Notification::make() - ->title('Bulk acknowledge completed') - ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') - ->success() - ->send(); - }), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } protected function buildAllMatchingQuery(): Builder { - $tenant = Tenant::current(); - $query = Finding::query(); - if (! $tenant) { + $tenantId = \Filament\Facades\Filament::getTenant()?->getKey(); + + if (! is_numeric($tenantId)) { return $query->whereRaw('1 = 0'); } - $query->where('tenant_id', $tenant->getKey()); + $query->where('tenant_id', (int) $tenantId); $query->where('status', Finding::STATUS_NEW); diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 2f07d97..0e38b89 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -6,6 +6,8 @@ use App\Filament\Resources\InventoryItemResource\Pages; use App\Models\InventoryItem; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Auth\Capabilities; @@ -26,7 +28,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; use UnitEnum; class InventoryItemResource extends Resource @@ -44,20 +45,34 @@ class InventoryItemResource extends Resource public static function canViewAny(): bool { $tenant = Tenant::current(); + $user = auth()->user(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + $capabilityResolver = app(CapabilityResolver::class); + + return $capabilityResolver->isMember($user, $tenant) + && $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant) { + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + $capabilityResolver = app(CapabilityResolver::class); + + if (! $capabilityResolver->isMember($user, $tenant)) { + return false; + } + + if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 56daeaf..e15908a 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -16,6 +16,8 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions\Action; use Filament\Actions\Action as HintAction; use Filament\Forms\Components\Hidden; @@ -24,7 +26,6 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Filament\Support\Enums\Size; -use Illuminate\Support\Facades\Gate; class ListInventoryItems extends ListRecords { @@ -40,244 +41,211 @@ protected function getHeaderWidgets(): array protected function getHeaderActions(): array { return [ - Action::make('run_inventory_sync') - ->label('Run Inventory Sync') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->form([ - Select::make('policy_types') - ->label('Policy types') - ->multiple() - ->searchable() - ->preload() - ->native(false) - ->hintActions([ - fn (Select $component): HintAction => HintAction::make('select_all_policy_types') - ->label('Select all') - ->link() - ->size(Size::Small) - ->action(function (InventorySyncService $inventorySyncService) use ($component): void { - $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); - }), - fn (Select $component): HintAction => HintAction::make('clear_policy_types') - ->label('Clear') - ->link() - ->size(Size::Small) - ->action(function () use ($component): void { - $component->state([]); - }), - ]) - ->options(function (): array { - return collect(InventoryPolicyTypeMeta::supported()) - ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) - ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) - ->mapWithKeys(function ($items, string $category): array { - $options = collect($items) - ->mapWithKeys(function (array $meta): array { - $type = (string) $meta['type']; - $label = (string) ($meta['label'] ?? $type); - $platform = (string) ($meta['platform'] ?? 'all'); + UiEnforcement::forAction( + Action::make('run_inventory_sync') + ->label('Run Inventory Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->form([ + Select::make('policy_types') + ->label('Policy types') + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->hintActions([ + fn (Select $component): HintAction => HintAction::make('select_all_policy_types') + ->label('Select all') + ->link() + ->size(Size::Small) + ->action(function (InventorySyncService $inventorySyncService) use ($component): void { + $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); + }), + fn (Select $component): HintAction => HintAction::make('clear_policy_types') + ->label('Clear') + ->link() + ->size(Size::Small) + ->action(function () use ($component): void { + $component->state([]); + }), + ]) + ->options(function (): array { + return collect(InventoryPolicyTypeMeta::supported()) + ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) + ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) + ->mapWithKeys(function ($items, string $category): array { + $options = collect($items) + ->mapWithKeys(function (array $meta): array { + $type = (string) $meta['type']; + $label = (string) ($meta['label'] ?? $type); + $platform = (string) ($meta['platform'] ?? 'all'); - return [$type => "{$label} • {$platform}"]; - }) - ->all(); + return [$type => "{$label} • {$platform}"]; + }) + ->all(); - return [$category => $options]; - }) - ->all(); - }) - ->columnSpanFull(), - Toggle::make('include_foundations') - ->label('Include foundation types') - ->helperText('Include scope tags, assignment filters, and notification templates.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Toggle::make('include_dependencies') - ->label('Include dependencies') - ->helperText('Include dependency extraction where supported.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Hidden::make('tenant_id') - ->default(fn (): ?string => Tenant::current()?->getKey()) - ->dehydrated(), - ]) - ->visible(function (): bool { - $user = auth()->user(); - if (! $user instanceof User) { - return false; - } + return [$category => $options]; + }) + ->all(); + }) + ->columnSpanFull(), + Toggle::make('include_foundations') + ->label('Include foundation types') + ->helperText('Include scope tags, assignment filters, and notification templates.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Toggle::make('include_dependencies') + ->label('Include dependencies') + ->helperText('Include dependency extraction where supported.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Hidden::make('tenant_id') + ->default(fn (): ?string => Tenant::current()?->getKey()) + ->dehydrated(), + ]) + ->visible(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return false; + } - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return false; - } + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return false; + } - return $user->canAccessTenant($tenant); - }) - ->disabled(function (): bool { - $user = auth()->user(); - if (! $user instanceof User) { - return true; - } + return $user->canAccessTenant($tenant); + }) + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + $user = auth()->user(); - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return true; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); - if (! $user instanceof User) { - return null; - } + $requestedTenantId = $data['tenant_id'] ?? null; + if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { + Notification::make() + ->title('Not allowed') + ->danger() + ->send(); - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return null; - } + return; + } - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) - ? null - : 'You do not have permission to start inventory sync.'; - }) - ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - abort(404); - } + $selectionPayload = $inventorySyncService->defaultSelectionPayload(); + if (array_key_exists('policy_types', $data)) { + $selectionPayload['policy_types'] = $data['policy_types']; + } + if (array_key_exists('include_foundations', $data)) { + $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; + } + if (array_key_exists('include_dependencies', $data)) { + $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; + } + $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); - $user = auth()->user(); - if (! $user instanceof User) { - abort(403, 'Not allowed'); - } + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user + ); - if (! $user->canAccessTenant($tenant)) { - abort(404); - } + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Inventory sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403, 'Not allowed'); - } + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - $requestedTenantId = $data['tenant_id'] ?? null; - if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { - Notification::make() - ->title('Not allowed') - ->danger() - ->send(); + return; + } - abort(403, 'Not allowed'); - } + // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) + $existing = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $computed['selection_hash']) + ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) + ->first(); - $selectionPayload = $inventorySyncService->defaultSelectionPayload(); - if (array_key_exists('policy_types', $data)) { - $selectionPayload['policy_types'] = $data['policy_types']; - } - if (array_key_exists('include_foundations', $data)) { - $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; - } - if (array_key_exists('include_dependencies', $data)) { - $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; - } - $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); + // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. + if ($existing instanceof InventorySyncRun) { + Notification::make() + ->title('Inventory sync already active') + ->body('A matching inventory sync run is already pending or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user - ); + return; + } - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Inventory sync already active') - ->body('This operation is already queued or running.') - ->warning() + $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); + + $policyTypes = $computed['selection']['policy_types'] ?? []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.dispatched', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'selection_hash' => $run->selection_hash, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + RunInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + inventorySyncRunId: (int) $run->id, + operationRun: $opRun + ); + }); + + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Action::make('view_run') - ->label('View Run') + ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - return; - } - - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.dispatched', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { - RunInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, - operationRun: $opRun - ); - }); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - }), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 44d8670..aedaf04 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -6,6 +6,8 @@ use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; @@ -21,7 +23,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; use UnitEnum; class InventorySyncRunResource extends Resource @@ -41,20 +42,31 @@ class InventorySyncRunResource extends Resource public static function canViewAny(): bool { $tenant = Tenant::current(); + $user = auth()->user(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant) { + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 51dc990..f2260eb 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -22,6 +22,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -41,7 +42,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyResource extends Resource @@ -363,569 +363,531 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ - Actions\Action::make('ignore') - ->label('Ignore') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (Policy $record): bool => $record->ignored_at === null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (Policy $record, HasTable $livewire) { - $tenant = Tenant::current(); + UiEnforcement::forTableAction( + Actions\Action::make('ignore') + ->label('Ignore') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->action(function (Policy $record): void { + $record->ignore(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->ignore(); - - Notification::make() - ->title('Policy ignored') - ->success() - ->send(); - }), - Actions\Action::make('restore') - ->label('Restore') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->visible(fn (Policy $record): bool => $record->ignored_at !== null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (Policy $record) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->unignore(); - - Notification::make() - ->title('Policy restored') - ->success() - ->send(); - }), - Actions\Action::make('sync') - ->label('Sync') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (Policy $record): bool { - if ($record->ignored_at !== null) { - return false; - } - - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return false; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->action(function (Policy $record, HasTable $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! $user instanceof User) { - abort(403); - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync_one', - inputs: [ - 'scope' => 'one', - 'policy_id' => (int) $record->getKey(), - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + ->title('Policy ignored') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to ignore policies.') + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('restore') + ->label('Restore') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at !== null) + ->action(function (Policy $record): void { + $record->unignore(); + + Notification::make() + ->title('Policy restored') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to restore policies.') + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('sync') + ->label('Sync') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->action(function (Policy $record, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync_one', + inputs: [ + 'scope' => 'one', + 'policy_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('export') + ->label('Export to Backup') + ->icon('heroicon-o-archive-box-arrow-down') + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->form([ + Forms\Components\TextInput::make('backup_name') + ->label('Backup Name') + ->required() + ->default(fn () => 'Backup '.now()->toDateTimeString()), + ]) + ->action(function (Policy $record, array $data): void { + $tenant = Tenant::current(); + $user = auth()->user(); - return; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), - Actions\Action::make('export') - ->label('Export to Backup') - ->icon('heroicon-o-archive-box-arrow-down') - ->visible(fn (Policy $record): bool => $record->ignored_at === null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->form([ - Forms\Components\TextInput::make('backup_name') - ->label('Backup Name') - ->required() - ->default(fn () => 'Backup '.now()->toDateTimeString()), - ]) - ->action(function (Policy $record, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } - if (! $user instanceof User) { - abort(403); - } + $ids = [(int) $record->getKey()]; - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); - $ids = [(int) $record->getKey()]; + $selectionIdentity = $selection->fromIds($ids); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.export', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void { - BulkPolicyExportJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - backupName: (string) $data['backup_name'], - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'backup_name' => (string) $data['backup_name'], - 'policy_count' => 1, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), - ])->icon('heroicon-o-ellipsis-vertical'), - ]) - ->bulkActions([ - BulkActionGroup::make([ - BulkAction::make('bulk_delete') - ->label('Ignore Policies') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return $value === 'ignored'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->form(function (Collection $records) { - if ($records->count() >= 20) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } - - return []; - }) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - return; - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids): void { - BulkPolicyDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); - - Notification::make() - ->title('Policy delete queued') - ->body("Queued deletion for {$count} policies.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($user) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_restore') - ->label('Restore Policies') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return ! in_array($value, [null, 'ignored'], true); - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - abort(403); - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.unignore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void { - if ($count >= 20) { - BulkPolicyUnignoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - - return; - } - - BulkPolicyUnignoreJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); - - if ($count >= 20) { - Notification::make() - ->title('Bulk restore started') - ->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) - ->send(); - } - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_sync') - ->label('Sync Policies') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return true; - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - return true; - } - - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return $value === 'ignored'; - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! $user instanceof User) { - abort(403); - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - $ids = $records - ->pluck('id') - ->map(static fn ($id): int => (int) $id) - ->unique() - ->sort() - ->values() - ->all(); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync', - inputs: [ - 'scope' => 'subset', - 'policy_ids' => $ids, - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_export') - ->label('Export to Backup') - ->icon('heroicon-o-archive-box-arrow-down') - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->form([ - Forms\Components\TextInput::make('backup_name') - ->label('Backup Name') - ->required() - ->default(fn () => 'Backup '.now()->toDateTimeString()), - ]) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - abort(403); - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.export', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void { - if ($count >= 20) { - BulkPolicyExportJob::dispatch( + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void { + BulkPolicyExportJob::dispatchSync( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), policyIds: $ids, backupName: (string) $data['backup_name'], operationRun: $operationRun, ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => 1, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Ignore Policies') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; - BulkPolicyExportJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - backupName: (string) $data['backup_name'], - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'backup_name' => (string) $data['backup_name'], - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); + return $value === 'ignored'; + }) + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids): void { + BulkPolicyDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); - if ($count >= 20) { Notification::make() - ->title('Bulk export started') - ->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.") + ->title('Policy delete queued') + ->body("Queued deletion for {$count} policies.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) ->duration(8000) ->sendToDatabase($user) ->send(); - } + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Policies') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return ! in_array($value, [null, 'ignored'], true); + }) + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.unignore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void { + if ($count >= 20) { + BulkPolicyUnignoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyUnignoreJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + } + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('bulk_sync') + ->label('Sync Policies') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return $value === 'ignored'; + }) + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + $ids = $records + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->all(); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => $ids, + ], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('bulk_export') + ->label('Export to Backup') + ->icon('heroicon-o-archive-box-arrow-down') + ->form([ + Forms\Components\TextInput::make('backup_name') + ->label('Backup Name') + ->required() + ->default(fn () => 'Backup '.now()->toDateTimeString()), + ]) + ->action(function (Collection $records, array $data): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void { + if ($count >= 20) { + BulkPolicyExportJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyExportJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); + + if ($count >= 20) { + Notification::make() + ->title('Bulk export started') + ->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + } + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index c13d94a..31a9eba 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -11,10 +11,10 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListPolicies extends ListRecords { @@ -23,109 +23,70 @@ class ListPolicies extends ListRecords protected function getHeaderActions(): array { return [ - Actions\Action::make('sync') - ->label('Sync from Intune') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (): bool { - $user = auth()->user(); + UiEnforcement::forAction( + Actions\Action::make('sync') + ->label('Sync from Intune') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->action(function (self $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $user instanceof User) { - return false; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } - $tenant = Tenant::current(); + $requestedTypes = array_map( + static fn (array $typeConfig): string => (string) $typeConfig['type'], + config('tenantpilot.supported_policy_types', []) + ); - return $tenant instanceof Tenant - && $user->canAccessTenant($tenant); - }) - ->disabled(function (): bool { - $user = auth()->user(); - $tenant = Tenant::current(); + sort($requestedTypes); - return ! ($user instanceof User - && $tenant instanceof Tenant - && Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); - $tenant = Tenant::current(); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: [ + 'scope' => 'all', + 'types' => $requestedTypes, + ], + initiator: $user + ); - if (! ($user instanceof User && $tenant instanceof Tenant)) { - return null; - } + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) - ? null - : 'You do not have permission to sync policies.'; - }) - ->action(function (self $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); + return; + } - if (! $user instanceof User) { - abort(403); - } - - if (! $tenant instanceof Tenant) { - abort(403); - } - - if (! $user->canAccessTenant($tenant)) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - $requestedTypes = array_map( - static fn (array $typeConfig): string => (string) $typeConfig['type'], - config('tenantpilot.supported_policy_types', []) - ); - - sort($requestedTypes); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync', - inputs: [ - 'scope' => 'all', - 'types' => $requestedTypes, - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { + SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); + }); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { - SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); - }); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to sync policies.') + ->destructive() + ->apply(), ]; } } diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index b3778d4..b63186b 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -5,17 +5,20 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\RestoreService; use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Support\Facades\Gate; class VersionsRelationManager extends RelationManager { @@ -23,6 +26,116 @@ class VersionsRelationManager extends RelationManager public function table(Table $table): Table { + $restoreToIntune = Actions\Action::make('restore_to_intune') + ->label('Restore to Intune') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") + ->modalSubheading('Creates a restore run using this policy version snapshot.') + ->form([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + ]) + ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + Notification::make() + ->title('Missing tenant or user context.') + ->danger() + ->send(); + + return; + } + + if ($record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + try { + $run = $restoreService->executeFromPolicyVersion( + tenant: $tenant, + version: $record, + dryRun: (bool) ($data['is_dry_run'] ?? true), + actorEmail: $user->email, + actorName: $user->name, + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Restore run started') + ->success() + ->send(); + + return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); + }); + + UiEnforcement::forAction($restoreToIntune) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + + $restoreToIntune + ->disabled(function (PolicyVersion $record): bool { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return true; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return true; + } + + return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); + }) + ->tooltip(function (PolicyVersion $record): ?string { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return null; + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + return UiTooltips::INSUFFICIENT_PERMISSION; + } + + return null; + }); + return $table ->columns([ Tables\Columns\TextColumn::make('version_number')->sortable(), @@ -38,61 +151,7 @@ public function table(Table $table): Table ->filters([]) ->headerActions([]) ->actions([ - Actions\Action::make('restore_to_intune') - ->label('Restore to Intune') - ->icon('heroicon-o-arrow-path-rounded-square') - ->color('danger') - ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') - ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') - ->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)) - ->requiresConfirmation() - ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") - ->modalSubheading('Creates a restore run using this policy version snapshot.') - ->form([ - Forms\Components\Toggle::make('is_dry_run') - ->label('Preview only (dry-run)') - ->default(true), - ]) - ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - if ($record->tenant_id !== $tenant->id) { - Notification::make() - ->title('Policy version belongs to a different tenant') - ->danger() - ->send(); - - return; - } - - try { - $run = $restoreService->executeFromPolicyVersion( - tenant: $tenant, - version: $record, - dryRun: (bool) ($data['is_dry_run'] ?? true), - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title('Restore run failed to start') - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make() - ->title('Restore run started') - ->success() - ->send(); - - return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); - }), + $restoreToIntune, Actions\ViewAction::make() ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 681625c..bb703c6 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -11,6 +11,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; @@ -21,6 +22,7 @@ use App\Support\Badges\TagBadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Carbon\CarbonImmutable; use Filament\Actions; @@ -39,7 +41,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyVersionResource extends Resource @@ -183,6 +184,294 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): Table { + $bulkPruneVersions = BulkAction::make('bulk_prune_versions') + ->label('Prune Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->form(function (Collection $records) { + $fields = [ + Forms\Components\TextInput::make('retention_days') + ->label('Retention Days') + ->helperText('Versions captured within the last N days will be skipped.') + ->numeric() + ->required() + ->default(90) + ->minValue(1), + ]; + + if ($records->count() >= 20) { + $fields[] = Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]); + } + + return $fields; + }) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $retentionDays = (int) ($data['retention_days'] ?? 90); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.prune', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { + BulkPolicyVersionPruneJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + retentionDays: $retentionDays, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + 'retention_days' => $retentionDays, + ], + emitQueuedNotification: false, + ); + + Notification::make() + ->title('Policy version prune queued') + ->body("Queued prune for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->duration(8000) + ->sendToDatabase($initiator); + + OperationUxPresenter::queuedToast('policy_version.prune') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkPruneVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + + $bulkRestoreVersions = BulkAction::make('bulk_restore_versions') + ->label('Restore Versions') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") + ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('policy_version.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkRestoreVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + + $bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions') + ->label('Force Delete Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") + ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + Notification::make() + ->title('Policy version force delete queued') + ->body("Queued force delete for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->duration(8000) + ->sendToDatabase($initiator); + + OperationUxPresenter::queuedToast('policy_version.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkForceDeleteVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + return $table ->columns([ Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), @@ -208,614 +497,318 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), Actions\ActionGroup::make([ - Actions\Action::make('restore_via_wizard') - ->label('Restore via Wizard') - ->icon('heroicon-o-arrow-path-rounded-square') - ->color('primary') - ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only' - || ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->tooltip(function (PolicyVersion $record): ?string { - if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) { - return 'You do not have permission to create restore runs.'; - } + (function (): Actions\Action { + $action = Actions\Action::make('restore_via_wizard') + ->label('Restore via Wizard') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") + ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') + ->visible(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); - if (($record->metadata['source'] ?? null) === 'metadata_only') { - return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } - return null; - }) - ->requiresConfirmation() - ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") - ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') - ->action(function (PolicyVersion $record) { - $tenant = Tenant::current(); - $user = auth()->user(); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + return $resolver->isMember($user, $tenant); + }) + ->disabled(function (PolicyVersion $record): bool { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return true; + } - if (! $tenant || $record->tenant_id !== $tenant->id) { - Notification::make() - ->title('Policy version belongs to a different tenant') - ->danger() - ->send(); + $tenant = Tenant::current(); + $user = auth()->user(); - return; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } - $policy = $record->policy; + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - if (! $policy) { - Notification::make() - ->title('Policy could not be found for this version') - ->danger() - ->send(); + if (! $resolver->isMember($user, $tenant)) { + return true; + } - return; - } + return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); + }) + ->tooltip(function (PolicyVersion $record): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); - $backupSet = BackupSet::create([ - 'tenant_id' => $tenant->id, - 'name' => sprintf( - 'Policy Version Restore • %s • v%d', - $policy->display_name, - $record->version_number - ), - 'created_by' => $user?->email, - 'status' => 'completed', - 'item_count' => 1, - 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return null; + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + return 'You do not have permission to create restore runs.'; + } + + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; + } + + return null; + }) + ->action(function (PolicyVersion $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } + + if (($record->metadata['source'] ?? null) === 'metadata_only') { + Notification::make() + ->title('Restore disabled for metadata-only snapshot') + ->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.') + ->warning() + ->send(); + + return; + } + + if (! $tenant || $record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + $policy = $record->policy; + + if (! $policy) { + Notification::make() + ->title('Policy could not be found for this version') + ->danger() + ->send(); + + return; + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $record->version_number + ), + 'created_by' => $user?->email, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ 'source' => 'policy_version', + 'display_name' => $policy->display_name, 'policy_version_id' => $record->id, 'policy_version_number' => $record->version_number, + 'version_captured_at' => $record->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, - ], - ]); + 'policy_version_id' => $record->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), + 'payload' => $record->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $record->assignments, + ]); - $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; - $scopeTagIds = $scopeTags['ids'] ?? null; - $scopeTagNames = $scopeTags['names'] ?? null; + return redirect()->to(RestoreRunResource::getUrl('create', [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])); + }); - $backupItemMetadata = [ - 'source' => 'policy_version', - 'display_name' => $policy->display_name, - 'policy_version_id' => $record->id, - 'policy_version_number' => $record->version_number, - 'version_captured_at' => $record->captured_at?->toIso8601String(), - ]; + return $action; + })(), + (function (): Actions\Action { + $action = Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => ! $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - if (is_array($scopeTagIds) && $scopeTagIds !== []) { - $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(403); + } - if (is_array($scopeTagNames) && $scopeTagNames !== []) { - $backupItemMetadata['scope_tag_names'] = $scopeTagNames; - } + $record->delete(); - $backupItem = BackupItem::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'policy_id' => $policy->id, - 'policy_version_id' => $record->id, - 'policy_identifier' => $policy->external_id, - 'policy_type' => $policy->policy_type, - 'platform' => $policy->platform, - 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), - 'payload' => $record->snapshot ?? [], - 'metadata' => $backupItemMetadata, - 'assignments' => $record->assignments, - ]); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - return redirect()->to(RestoreRunResource::getUrl('create', [ - 'backup_set_id' => $backupSet->id, - 'scope_mode' => 'selected', - 'backup_item_ids' => [$backupItem->id], - ])); - }), - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => ! $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; + Notification::make() + ->title('Policy version archived') + ->success() + ->send(); + }); - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (PolicyVersion $record): ?string { - $user = auth()->user(); - $tenant = $record->tenant; + return $action; + })(), + (function (): Actions\Action { + $action = Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(403); + } - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.force_deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->forceDelete(); - $record->delete(); + Notification::make() + ->title('Policy version permanently deleted') + ->success() + ->send(); + }); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.deleted', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - Notification::make() - ->title('Policy version archived') - ->success() - ->send(); - }), - Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; + return $action; + })(), - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } + (function (): Actions\Action { + $action = Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (PolicyVersion $record): ?string { - $user = auth()->user(); - $tenant = $record->tenant; + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(403); + } - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } + $record->restore(); - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.restored', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + Notification::make() + ->title('Policy version restored') + ->success() + ->send(); + }); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.force_deleted', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - $record->forceDelete(); - - Notification::make() - ->title('Policy version permanently deleted') - ->success() - ->send(); - }), - - Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (PolicyVersion $record): ?string { - $user = auth()->user(); - $tenant = $record->tenant; - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; - - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->restore(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.restored', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } - - Notification::make() - ->title('Policy version restored') - ->success() - ->send(); - }), + return $action; + })(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('bulk_prune_versions') - ->label('Prune Versions') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->form(function (Collection $records) { - $fields = [ - Forms\Components\TextInput::make('retention_days') - ->label('Retention Days') - ->helperText('Versions captured within the last N days will be skipped.') - ->numeric() - ->required() - ->default(90) - ->minValue(1), - ]; - - if ($records->count() >= 20) { - $fields[] = Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]); - } - - return $fields; - }) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - $retentionDays = (int) ($data['retention_days'] ?? 90); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.prune', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { - BulkPolicyVersionPruneJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - retentionDays: $retentionDays, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - 'retention_days' => $retentionDays, - ], - emitQueuedNotification: false, - ); - - if ($initiator instanceof User) { - Notification::make() - ->title('Policy version prune queued') - ->body("Queued prune for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - } - - OperationUxPresenter::queuedToast('policy_version.prune') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_restore_versions') - ->label('Restore Versions') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return ! $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") - ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkPolicyVersionRestoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('policy_version.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_force_delete_versions') - ->label('Force Delete Versions') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return ! $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") - ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') - ->form([ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkPolicyVersionForceDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - ], - emitQueuedNotification: false, - ); - - if ($initiator instanceof User) { - Notification::make() - ->title('Policy version force delete queued') - ->body("Queued force delete for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - } - - OperationUxPresenter::queuedToast('policy_version.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + $bulkPruneVersions, + $bulkRestoreVersions, + $bulkForceDeleteVersions, ]), ]); } diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 6e2377f..7153fa9 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -11,6 +11,7 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; @@ -18,6 +19,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Forms\Components\TextInput; @@ -29,7 +31,6 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Gate; use UnitEnum; class ProviderConnectionResource extends Resource @@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource protected static ?string $recordTitleAttribute = 'display_name'; + protected static function hasTenantCapability(string $capability): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, $capability); + } + public static function form(Schema $schema): Schema { return $schema @@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema TextInput::make('display_name') ->label('Display name') ->required() - ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->maxLength(255), TextInput::make('entra_tenant_id') ->label('Entra tenant ID') ->required() ->maxLength(255) - ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->rules(['uuid']), Toggle::make('is_default') ->label('Default connection') - ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->helperText('Exactly one default connection is required per tenant/provider.'), TextInput::make('status') ->label('Status') @@ -146,534 +163,473 @@ public static function table(Table $table): Table ]) ->actions([ Actions\ActionGroup::make([ - Actions\EditAction::make(), + UiEnforcement::forAction( + Actions\EditAction::make() + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), - Actions\Action::make('check_connection') - ->label('Check connection') - ->icon('heroicon-o-check-badge') - ->color('success') - ->visible(function (ProviderConnection $record): bool { - $user = auth()->user(); + UiEnforcement::forAction( + Actions\Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $user instanceof User) { - return false; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - $tenant = Tenant::current(); + $initiator = $user; - if (! $tenant instanceof Tenant) { - return false; - } + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); - return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); - if (! $tenant instanceof Tenant) { - return true; - } + return; + } - return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); - if (! $tenant instanceof Tenant) { - return null; - } + return; + } - return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'provider.connection.check', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderConnectionHealthCheckJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope busy') - ->body('Another provider operation is already running for this connection.') - ->warning() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'deduped') { Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - Actions\Action::make('inventory_sync') - ->label('Inventory sync') - ->icon('heroicon-o-arrow-path') - ->color('info') - ->visible(function (ProviderConnection $record): bool { - $user = auth()->user(); + $initiator = $user; - if (! $user instanceof User) { - return false; - } + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); - $tenant = Tenant::current(); + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); - if (! $tenant instanceof Tenant) { - return false; - } + return; + } - return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); - if (! $tenant instanceof Tenant) { - return true; - } + return; + } - return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return null; - } - - return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'inventory.sync', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + if (! $tenant instanceof Tenant) { + return; + } - return; - } + $record->makeDefault(); - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; - Actions\Action::make('compliance_snapshot') - ->label('Compliance snapshot') - ->icon('heroicon-o-shield-check') - ->color('info') - ->visible(function (ProviderConnection $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return false; - } - - return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return true; - } - - return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return null; - } - - return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'compliance.snapshot', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderComplianceSnapshotJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), - - Actions\Action::make('set_default') - ->label('Set as default') - ->icon('heroicon-o-star') - ->color('primary') - ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) - && $record->status !== 'disabled' - && ! $record->is_default) - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $record->makeDefault(); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.default_set', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Default connection updated') - ->success() - ->send(); - }), - - Actions\Action::make('update_credentials') - ->label('Update credentials') - ->icon('heroicon-o-key') - ->color('primary') - ->modalDescription('Client secret is stored encrypted and will never be shown again.') - ->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) - ->form([ - TextInput::make('client_id') - ->label('Client ID') - ->required() - ->maxLength(255), - TextInput::make('client_secret') - ->label('Client secret') - ->password() - ->required() - ->maxLength(255), - ]) - ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $credentials->upsertClientSecretCredential( - connection: $record, - clientId: (string) $data['client_id'], - clientSecret: (string) $data['client_secret'], - ); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.credentials_updated', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Credentials updated') - ->success() - ->send(); - }), - - Actions\Action::make('enable_connection') - ->label('Enable connection') - ->icon('heroicon-o-play') - ->color('success') - ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) - && $record->status === 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => $status, - 'health_status' => 'unknown', - 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.enabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, - 'credentials_present' => $hadCredentials, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Default connection updated') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('update_credentials') + ->label('Update credentials') + ->icon('heroicon-o-key') + ->color('primary') + ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->form([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client secret') + ->password() + ->required() + ->maxLength(255), + ]) + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); + + return; + } + + Notification::make() + ->title('Provider connection enabled') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => 'disabled', + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Provider connection disabled') ->warning() ->send(); - - return; - } - - Notification::make() - ->title('Provider connection enabled') - ->success() - ->send(); - }), - - Actions\Action::make('disable_connection') - ->label('Disable connection') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) - && $record->status !== 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => 'disabled', - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.disabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Provider connection disabled') - ->warning() - ->send(); - }), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index bccec5d..ef44984 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -10,18 +10,19 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; class EditProviderConnection extends EditRecord { @@ -115,559 +116,567 @@ protected function getHeaderActions(): array ->visible(false), Actions\ActionGroup::make([ - Action::make('view_last_check_run') - ->label('View last check run') - ->icon('heroicon-o-eye') - ->color('gray') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows(Capabilities::PROVIDER_VIEW, $tenant) - && OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('type', 'provider.connection.check') - ->where('context->provider_connection_id', (int) $record->getKey()) - ->exists()) - ->url(function (ProviderConnection $record): ?string { - $tenant = Tenant::current(); + UiEnforcement::forAction( + Action::make('view_last_check_run') + ->label('View last check run') + ->icon('heroicon-o-eye') + ->color('gray') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->exists()) + ->url(function (ProviderConnection $record): ?string { + $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return null; - } + if (! $tenant instanceof Tenant) { + return null; + } - $run = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('type', 'provider.connection.check') - ->where('context->provider_connection_id', (int) $record->getKey()) - ->orderByDesc('id') - ->first(); + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->orderByDesc('id') + ->first(); - if (! $run instanceof OperationRun) { - return null; - } + if (! $run instanceof OperationRun) { + return null; + } - return OperationRunLinks::view($run, $tenant); - }), + return OperationRunLinks::view($run, $tenant); + }) + ) + ->requireCapability(Capabilities::PROVIDER_VIEW) + ->tooltip('You do not have permission to view provider connections.') + ->preserveVisibility() + ->apply(), - Action::make('check_connection') - ->label('Check connection') - ->icon('heroicon-o-check-badge') - ->color('success') - ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + UiEnforcement::forAction( + Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); - return $tenant instanceof Tenant - && $user instanceof User - && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } + if (! $user->canAccessTenant($tenant)) { + abort(404); + } - return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); + $initiator = $user; - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'provider.connection.check', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderConnectionHealthCheckJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope busy') - ->body('Another provider operation is already running for this connection.') - ->warning() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), - return; - } + UiEnforcement::forAction( + Action::make('update_credentials') + ->label('Update credentials') + ->icon('heroicon-o-key') + ->color('primary') + ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->visible(fn (): bool => $tenant instanceof Tenant) + ->form([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client secret') + ->password() + ->required() + ->maxLength(255), + ]) + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + if (! $tenant instanceof Tenant) { + abort(404); + } - return; - } + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; - Action::make('update_credentials') - ->label('Update credentials') - ->icon('heroicon-o-key') - ->color('primary') - ->modalDescription('Client secret is stored encrypted and will never be shown again.') - ->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) - ->form([ - TextInput::make('client_id') - ->label('Client ID') - ->required() - ->maxLength(255), - TextInput::make('client_secret') - ->label('Client secret') - ->password() - ->required() - ->maxLength(255), - ]) - ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $credentials->upsertClientSecretCredential( - connection: $record, - clientId: (string) $data['client_id'], - clientSecret: (string) $data['client_secret'], - ); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.credentials_updated', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Credentials updated') - ->success() - ->send(); - }), + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), - Action::make('set_default') - ->label('Set as default') - ->icon('heroicon-o-star') - ->color('primary') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) - && $record->status !== 'disabled' - && ! $record->is_default - && ProviderConnection::query() - ->where('tenant_id', $tenant->getKey()) - ->where('provider', $record->provider) - ->count() > 1) - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); + UiEnforcement::forAction( + Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && $record->status !== 'disabled' + && ! $record->is_default + && ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->where('provider', $record->provider) + ->count() > 1) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant) { + abort(404); + } - $record->makeDefault(); + $record->makeDefault(); - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.default_set', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Default connection updated') - ->success() - ->send(); - }), - - Action::make('inventory_sync') - ->label('Inventory sync') - ->icon('heroicon-o-arrow-path') - ->color('info') - ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - return $tenant instanceof Tenant - && $user instanceof User - && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'inventory.sync', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() + ->title('Default connection updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), - return; - } + UiEnforcement::forAction( + Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'deduped') { Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), - return; - } + UiEnforcement::forAction( + Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), + if (! $tenant instanceof Tenant) { + return; + } - Action::make('compliance_snapshot') - ->label('Compliance snapshot') - ->icon('heroicon-o-shield-check') - ->color('info') - ->visible(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; - return $tenant instanceof Tenant - && $user instanceof User - && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; - return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) - ? null - : 'You do not have permission to run provider operations.'; - }) - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant, 404); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($tenant), 404); - abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'compliance.snapshot', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderComplianceSnapshotJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), - - Action::make('enable_connection') - ->label('Enable connection') - ->icon('heroicon-o-play') - ->color('success') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) - && $record->status === 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => $status, - 'health_status' => 'unknown', - 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.enabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, - 'credentials_present' => $hadCredentials, + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); + + return; + } - if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Provider connection enabled') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => 'disabled', + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Provider connection disabled') ->warning() ->send(); - - return; - } - - Notification::make() - ->title('Provider connection enabled') - ->success() - ->send(); - }), - - Action::make('disable_connection') - ->label('Disable connection') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) - && $record->status !== 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); - - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => 'disabled', - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.disabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Provider connection disabled') - ->warning() - ->send(); - }), + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') @@ -679,7 +688,17 @@ protected function getFormActions(): array { $tenant = Tenant::current(); - if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) { + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return [ + $this->getCancelFormAction(), + ]; + } + + $capabilityResolver = app(CapabilityResolver::class); + + if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) { return parent::getFormActions(); } @@ -692,7 +711,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(404); + } + + $capabilityResolver = app(CapabilityResolver::class); + + if (! $capabilityResolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) { + abort(403); + } return parent::handleRecordUpdate($record, $data); } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index bf1780c..ec91c7f 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -3,6 +3,8 @@ namespace App\Filament\Resources\ProviderConnectionResource\Pages; use App\Filament\Resources\ProviderConnectionResource; +use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -13,11 +15,13 @@ class ListProviderConnections extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() - ->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())) - ->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()) - ? null - : 'You do not have permission to create provider connections.'), + UiEnforcement::forAction( + Actions\CreateAction::make() + ->authorize(fn (): bool => true) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to create provider connections.') + ->apply(), ]; } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 45f256e..34f6bc7 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -14,6 +14,7 @@ use App\Models\Tenant; use App\Models\User; use App\Rules\SkipOrUuidRule; +use App\Services\Auth\CapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; @@ -27,6 +28,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; use BackedEnum; @@ -50,7 +52,6 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -65,8 +66,18 @@ class RestoreRunResource extends Resource public static function canCreate(): bool { - return ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); } public static function form(Schema $schema): Schema @@ -748,96 +759,65 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ - Actions\Action::make('rerun') - ->label('Rerun') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (RestoreRun $record): bool { - $backupSet = $record->backupSet; + UiEnforcement::forTableAction( + Actions\Action::make('rerun') + ->label('Rerun') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(function (RestoreRun $record): bool { + $backupSet = $record->backupSet; - return ! $record->trashed() - && $record->isDeletable() - && $backupSet !== null - && ! $backupSet->trashed(); - }) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function ( - RestoreRun $record, - RestoreService $restoreService, - \App\Services\Intune\AuditLogger $auditLogger, - HasTable $livewire - ) { - $currentTenant = Tenant::current(); + return ! $record->trashed() + && $record->isDeletable() + && $backupSet !== null + && ! $backupSet->trashed(); + }) + ->action(function ( + RestoreRun $record, + RestoreService $restoreService, + \App\Services\Intune\AuditLogger $auditLogger, + HasTable $livewire + ) { + $tenant = $record->tenant; + $backupSet = $record->backupSet; - abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403); - - $tenant = $record->tenant; - $backupSet = $record->backupSet; - - if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) { - Notification::make() - ->title('Restore run cannot be rerun') - ->body('Restore run or backup set is archived or unavailable.') - ->warning() - ->send(); - - return; - } - - if (! (bool) $record->is_dry_run) { - $selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null; - $groupMapping = is_array($record->group_mapping) ? $record->group_mapping : []; - $actorEmail = auth()->user()?->email; - $actorName = auth()->user()?->name; - $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; - $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); - - $preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds); - $metadata = [ - 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', - 'environment' => app()->environment('production') ? 'prod' : 'test', - 'highlander_label' => $highlanderLabel, - 'confirmed_at' => now()->toIso8601String(), - 'confirmed_by' => $actorEmail, - 'confirmed_by_name' => $actorName, - 'rerun_of_restore_run_id' => $record->id, - ]; - - $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( - tenantId: (int) $tenant->getKey(), - backupSetId: (int) $backupSet->getKey(), - selectedItemIds: $selectedItemIds, - groupMapping: $groupMapping, - ); - - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); - - if ($existing) { + if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) { Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() + ->title('Restore run cannot be rerun') + ->body('Restore run or backup set is archived or unavailable.') + ->warning() ->send(); return; } - try { - $newRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => false, - 'status' => RestoreRunStatus::Queued->value, - 'idempotency_key' => $idempotencyKey, - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'metadata' => $metadata, - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); - } catch (QueryException $exception) { + if (! (bool) $record->is_dry_run) { + $selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null; + $groupMapping = is_array($record->group_mapping) ? $record->group_mapping : []; + $actorEmail = auth()->user()?->email; + $actorName = auth()->user()?->name; + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + $preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds); + $metadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => $highlanderLabel, + 'confirmed_at' => now()->toIso8601String(), + 'confirmed_by' => $actorEmail, + 'confirmed_by_name' => $actorName, + 'rerun_of_restore_run_id' => $record->id, + ]; + + $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( + tenantId: (int) $tenant->getKey(), + backupSetId: (int) $backupSet->getKey(), + selectedItemIds: $selectedItemIds, + groupMapping: $groupMapping, + ); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { @@ -850,27 +830,96 @@ public static function table(Table $table): Table return; } - throw $exception; + try { + $newRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'idempotency_key' => $idempotencyKey, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } catch (QueryException $exception) { + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return; + } + + throw $exception; + } + + $auditLogger->log( + tenant: $tenant, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $newRun->id, + 'backup_set_id' => $backupSet->id, + 'rerun_of_restore_run_id' => $record->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + ); + + ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + + $auditLogger->log( + tenant: $tenant, + action: 'restore_run.rerun', + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + context: [ + 'metadata' => [ + 'original_restore_run_id' => $record->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + ); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast('restore.execute') + ->send(); + + return; } - $auditLogger->log( - tenant: $tenant, - action: 'restore.queued', - context: [ - 'metadata' => [ - 'restore_run_id' => $newRun->id, - 'backup_set_id' => $backupSet->id, - 'rerun_of_restore_run_id' => $record->id, - ], - ], - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'restore_run', - resourceId: (string) $newRun->id, - status: 'success', - ); + try { + $newRun = $restoreService->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $record->requested_items ?? null, + dryRun: (bool) $record->is_dry_run, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + groupMapping: $record->group_mapping ?? [] + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); - ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + return; + } $auditLogger->log( tenant: $tenant, @@ -883,416 +932,378 @@ public static function table(Table $table): Table 'original_restore_run_id' => $record->id, 'backup_set_id' => $backupSet->id, ], - ], - actorEmail: $actorEmail, - actorName: $actorName, + ] ); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record->restore(); - return; - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.restored', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - try { - $newRun = $restoreService->execute( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $record->requested_items ?? null, - dryRun: (bool) $record->is_dry_run, - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $record->group_mapping ?? [] - ); - } catch (\Throwable $throwable) { Notification::make() - ->title('Restore run failed to start') - ->body($throwable->getMessage()) - ->danger() + ->title('Restore run restored') + ->success() ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if (! $record->isDeletable()) { + Notification::make() + ->title('Restore run cannot be archived') + ->body("Not deletable (status: {$record->status})") + ->warning() + ->send(); - return; - } + return; + } - $auditLogger->log( - tenant: $tenant, - action: 'restore_run.rerun', - resourceType: 'restore_run', - resourceId: (string) $newRun->id, - status: 'success', - context: [ - 'metadata' => [ - 'original_restore_run_id' => $record->id, - 'backup_set_id' => $backupSet->id, - ], - ] - ); + $record->delete(); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast('restore.execute') - ->send(); - }), - Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - $tenant = Tenant::current(); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->restore(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.restored', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - Notification::make() - ->title('Restore run restored') - ->success() - ->send(); - }), - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - if (! $record->isDeletable()) { Notification::make() - ->title('Restore run cannot be archived') - ->body("Not deletable (status: {$record->status})") - ->warning() + ->title('Restore run archived') + ->success() ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.force_deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - return; - } + $record->forceDelete(); - $record->delete(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.deleted', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - Notification::make() - ->title('Restore run archived') - ->success() - ->send(); - }), - Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => $record->trashed()) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.force_deleted', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - $record->forceDelete(); - - Notification::make() - ->title('Restore run permanently deleted') - ->success() - ->send(); - }), + Notification::make() + ->title('Restore run permanently deleted') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->preserveVisibility() + ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('bulk_delete') - ->label('Archive Restore Runs') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Archive Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - $isOnlyTrashed = in_array($value, [0, '0', false], true); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return $isOnlyTrashed; - }) - ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 20) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + return $isOnlyTrashed; + }) + ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - if (! $tenant instanceof Tenant) { - return; - } + if (! $tenant instanceof Tenant) { + return; + } - abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; - $initiator = $user instanceof User ? $user : null; + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'restore_run.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkRestoreRunDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('restore_run.delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_restore') - ->label('Restore Restore Runs') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return ! $isOnlyTrashed; - }) - ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") - ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'restore_run.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { - if ($count >= 20) { - BulkRestoreRunRestoreJob::dispatch( + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkRestoreRunDeleteJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), restoreRunIds: $ids, operationRun: $operationRun, ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast('restore_run.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - BulkRestoreRunRestoreJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Restore Runs') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - OperationUxPresenter::queuedToast('restore_run.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + $isOnlyTrashed = in_array($value, [0, '0', false], true); - BulkAction::make('bulk_force_delete') - ->label('Force Delete Restore Runs') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") + ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $isOnlyTrashed = in_array($value, [0, '0', false], true); + if (! $tenant instanceof Tenant) { + return; + } - return ! $isOnlyTrashed; - }) - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") - ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.') - ->form([ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]) - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + $initiator = $user instanceof User ? $user : null; - if (! $tenant instanceof Tenant) { - return; - } + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $initiator = $user instanceof User ? $user : null; + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + return; + } - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'restore_run.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { - if ($count >= 20) { - BulkRestoreRunForceDeleteJob::dispatch( + BulkRestoreRunRestoreJob::dispatchSync( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), restoreRunIds: $ids, operationRun: $operationRun, ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast('restore_run.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - BulkRestoreRunForceDeleteJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); + UiEnforcement::forBulkAction( + BulkAction::make('bulk_force_delete') + ->label('Force Delete Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - OperationUxPresenter::queuedToast('restore_run.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") + ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkRestoreRunForceDeleteJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('restore_run.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ]), ]); } @@ -1491,10 +1502,23 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array public static function createRestoreRun(array $data): RestoreRun { - /** @var Tenant $tenant */ $tenant = Tenant::current(); + $user = auth()->user(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } /** @var BackupSet $backupSet */ $backupSet = BackupSet::findOrFail($data['backup_set_id']); diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 19986f2..96fe678 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -5,12 +5,13 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; use Livewire\Attributes\On; class CreateRestoreRun extends CreateRecord @@ -23,7 +24,21 @@ protected function authorizeAccess(): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(404); + } + + $capabilityResolver = app(CapabilityResolver::class); + + if (! $capabilityResolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } } public function getSteps(): array diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index b03a866..48a2e68 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; @@ -43,7 +44,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Throwable; @@ -79,7 +79,11 @@ public static function canEdit(Model $record): bool return false; } - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $record instanceof Tenant + && $resolver->can($user, $record, Capabilities::TENANT_MANAGE); } public static function canDelete(Model $record): bool @@ -90,7 +94,11 @@ public static function canDelete(Model $record): bool return false; } - return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $record instanceof Tenant + && $resolver->can($user, $record, Capabilities::TENANT_DELETE); } public static function canDeleteAny(): bool @@ -106,36 +114,16 @@ public static function canDeleteAny(): bool private static function userCanManageAnyTenant(User $user): bool { - $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); - - if ($tenantIds->isEmpty()) { - return false; - } - - foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { - if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) { - return true; - } - } - - return false; + return $user->tenantMemberships() + ->pluck('role') + ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE)); } private static function userCanDeleteAnyTenant(User $user): bool { - $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); - - if ($tenantIds->isEmpty()) { - return false; - } - - foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { - if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) { - return true; - } - } - - return false; + return $user->tenantMemberships() + ->pluck('role') + ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE)); } public static function form(Schema $schema): Schema @@ -299,7 +287,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC); }) ->tooltip(function (Tenant $record): ?string { $user = auth()->user(); @@ -308,15 +299,30 @@ public static function table(Table $table): Table return null; } - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record) + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $record, Capabilities::TENANT_SYNC) ? null : 'You do not have permission to sync this tenant.'; }) ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { $user = auth()->user(); - abort_unless($user instanceof User, 403); - abort_unless($user->canAccessTenant($record), 404); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record), 403); + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($record)) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) { + abort(403); + } /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -416,7 +422,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); }) ->action(function (Tenant $record, AuditLogger $auditLogger): void { $user = auth()->user(); @@ -425,7 +434,10 @@ public static function table(Table $table): Table abort(403); } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { abort(403); } @@ -452,7 +464,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); }) ->tooltip(function (Tenant $record): ?string { $user = auth()->user(); @@ -461,7 +476,10 @@ public static function table(Table $table): Table return null; } - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record) + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $record, Capabilities::TENANT_MANAGE) ? null : 'You do not have permission to manage tenant consent.'; }) @@ -485,7 +503,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); }) ->action(function ( Tenant $record, @@ -500,7 +521,10 @@ public static function table(Table $table): Table abort(403); } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { abort(403); } @@ -520,7 +544,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); }) ->action(function (Tenant $record, AuditLogger $auditLogger) { $user = auth()->user(); @@ -529,7 +556,10 @@ public static function table(Table $table): Table abort(403); } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { abort(403); } @@ -567,7 +597,10 @@ public static function table(Table $table): Table return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); }) ->action(function (?Tenant $record, AuditLogger $auditLogger) { if ($record === null) { @@ -580,7 +613,10 @@ public static function table(Table $table): Table abort(403); } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { abort(403); } @@ -648,9 +684,12 @@ public static function table(Table $table): Table return; } + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + $eligible = $records ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) - ->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); + ->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)); if ($eligible->isEmpty()) { Notification::make() @@ -893,7 +932,10 @@ public static function rbacAction(): Actions\Action return true; } - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); }) ->requiresConfirmation() ->action(function ( @@ -908,7 +950,10 @@ public static function rbacAction(): Actions\Action abort(403); } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { abort(403); } diff --git a/app/Filament/Resources/TenantResource/Pages/EditTenant.php b/app/Filament/Resources/TenantResource/Pages/EditTenant.php index 19117cf..cf18b64 100644 --- a/app/Filament/Resources/TenantResource/Pages/EditTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/EditTenant.php @@ -4,11 +4,11 @@ use App\Filament\Resources\TenantResource; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; +use Filament\Actions\Action; use Filament\Resources\Pages\EditRecord; -use Illuminate\Support\Facades\Gate; class EditTenant extends EditRecord { @@ -18,42 +18,21 @@ protected function getHeaderActions(): array { return [ Actions\ViewAction::make(), - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed()) - ->disabled(function (): bool { - $tenant = $this->record; - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = $this->record; - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant) - ? null - : 'You do not have permission to archive tenants.'; - }) - ->action(function (): void { - $tenant = $this->record; - $user = auth()->user(); - - abort_unless($tenant instanceof Tenant && $user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant), 403); - - $tenant->delete(); - }), + UiEnforcement::forAction( + Action::make('archive') + ->label('Archive') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Tenant $record): bool => ! $record->trashed()) + ->action(function (Tenant $record): void { + $record->delete(); + }) + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->tooltip('You do not have permission to archive tenants.') + ->preserveVisibility() + ->destructive() + ->apply(), ]; } } diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index 4b84f88..78ddf84 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -7,14 +7,14 @@ use App\Models\User; use App\Services\Auth\TenantMembershipManager; use App\Support\Auth\Capabilities; -use Filament\Actions; +use App\Support\Rbac\UiEnforcement; +use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Gate; class TenantMembershipsRelationManager extends RelationManager { @@ -40,185 +40,166 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->since(), ]) ->headerActions([ - Actions\Action::make('add_member') - ->label(__('Add member')) - ->icon('heroicon-o-plus') - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + UiEnforcement::forTableAction( + Action::make('add_member') + ->label(__('Add member')) + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('user_id') + ->label(__('User')) + ->required() + ->searchable() + ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (array $data, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - if (! $tenant instanceof Tenant) { - return false; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->form([ - Forms\Components\Select::make('user_id') - ->label(__('User')) - ->required() - ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), - Forms\Components\Select::make('role') - ->label(__('Role')) - ->required() - ->options([ - 'owner' => __('Owner'), - 'manager' => __('Manager'), - 'operator' => __('Operator'), - 'readonly' => __('Readonly'), - ]), - ]) - ->action(function (array $data, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - abort(404); - } + $member = User::query()->find((int) $data['user_id']); + if (! $member) { + Notification::make()->title(__('User not found'))->danger()->send(); - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + return; + } - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } + try { + $manager->addMember( + tenant: $tenant, + actor: $actor, + member: $member, + role: (string) $data['role'], + source: 'manual', + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to add member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - $member = User::query()->find((int) $data['user_id']); - if (! $member) { - Notification::make()->title(__('User not found'))->danger()->send(); + return; + } - return; - } - - try { - $manager->addMember( - tenant: $tenant, - actor: $actor, - member: $member, - role: (string) $data['role'], - source: 'manual', - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to add member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make()->title(__('Member added'))->success()->send(); - $this->resetTable(); - }), + Notification::make()->title(__('Member added'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->apply(), ]) ->actions([ - Actions\Action::make('change_role') - ->label(__('Change role')) - ->icon('heroicon-o-pencil') - ->requiresConfirmation() - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + UiEnforcement::forTableAction( + Action::make('change_role') + ->label(__('Change role')) + ->icon('heroicon-o-pencil') + ->requiresConfirmation() + ->form([ + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - if (! $tenant instanceof Tenant) { - return false; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->form([ - Forms\Components\Select::make('role') - ->label(__('Role')) - ->required() - ->options([ - 'owner' => __('Owner'), - 'manager' => __('Manager'), - 'operator' => __('Operator'), - 'readonly' => __('Readonly'), - ]), - ]) - ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - abort(404); - } + try { + $manager->changeRole( + tenant: $tenant, + actor: $actor, + membership: $record, + newRole: (string) $data['role'], + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to change role')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + return; + } - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } + Notification::make()->title(__('Role updated'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->apply(), - try { - $manager->changeRole( - tenant: $tenant, - actor: $actor, - membership: $record, - newRole: (string) $data['role'], - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to change role')) - ->body($throwable->getMessage()) - ->danger() - ->send(); + UiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (TenantMembership $record, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - return; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - Notification::make()->title(__('Role updated'))->success()->send(); - $this->resetTable(); - }), - Actions\Action::make('remove') - ->label(__('Remove')) - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - return false; - } + try { + $manager->removeMember($tenant, $actor, $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->action(function (TenantMembership $record, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + return; + } - if (! $tenant instanceof Tenant) { - abort(404); - } - - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } - - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } - - try { - $manager->removeMember($tenant, $actor, $record); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to remove member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make()->title(__('Member removed'))->success()->send(); - $this->resetTable(); - }), + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->destructive() + ->apply(), ]) ->bulkActions([]); } diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php index ff49225..b11bf1b 100644 --- a/app/Policies/FindingPolicy.php +++ b/app/Policies/FindingPolicy.php @@ -5,9 +5,9 @@ use App\Models\Finding; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; use Illuminate\Auth\Access\HandlesAuthorization; -use Illuminate\Support\Facades\Gate; class FindingPolicy { @@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool return false; } - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE); } } diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php index dc2a4a4..04bf870 100644 --- a/app/Services/Auth/RoleCapabilityMap.php +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -19,6 +19,8 @@ class RoleCapabilityMap Capabilities::TENANT_MANAGE, Capabilities::TENANT_DELETE, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_MANAGE, @@ -40,6 +42,8 @@ class RoleCapabilityMap Capabilities::TENANT_VIEW, Capabilities::TENANT_MANAGE, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, @@ -58,6 +62,8 @@ class RoleCapabilityMap TenantRole::Operator->value => [ Capabilities::TENANT_VIEW, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW, diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 3c2ac8c..47bcc50 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -24,6 +24,12 @@ class Capabilities public const TENANT_SYNC = 'tenant.sync'; + // Inventory + public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; + + // Findings + public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; + // Tenant memberships public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view'; diff --git a/app/Support/Rbac/TenantAccessContext.php b/app/Support/Rbac/TenantAccessContext.php new file mode 100644 index 0000000..07350f1 --- /dev/null +++ b/app/Support/Rbac/TenantAccessContext.php @@ -0,0 +1,48 @@ +isMember; + } + + /** + * Members without capability should receive 403 (forbidden). + */ + public function shouldDenyAsForbidden(): bool + { + return $this->isMember && ! $this->hasCapability; + } + + /** + * User is authorized to perform the action. + */ + public function isAuthorized(): bool + { + return $this->isMember && $this->hasCapability; + } +} diff --git a/app/Support/Rbac/UiEnforcement.php b/app/Support/Rbac/UiEnforcement.php new file mode 100644 index 0000000..232cd42 --- /dev/null +++ b/app/Support/Rbac/UiEnforcement.php @@ -0,0 +1,414 @@ +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 tenant membership 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; + } + + /** + * 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; + } + + /** + * 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; + } + + $tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION; + + $this->action->disabled(function (?Model $record = null) { + $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) { + $context = $this->resolveContextWithRecord($record); + + if ($context->isMember && ! $context->hasCapability) { + return $tooltip; + } + + return null; + }); + } + + /** + * 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 { + $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); + } + }); + } + + /** + * 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 Tenant + $tenant = $this->resolveTenantWithRecord($record); + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + 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 Tenant, use it + * 2. If $this->record is set (for forTableAction), resolve it + * 3. Fall back to Filament::getTenant() + */ + private function resolveTenantWithRecord(?Model $record = null): ?Tenant + { + // If a record is passed directly (from closure parameter), check if it's a Tenant + if ($record instanceof Tenant) { + return $record; + } + + // 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 Tenant) { + return $resolved; + } + } + + // Default: use Filament's current tenant + return Filament::getTenant(); + } +} diff --git a/app/Support/Rbac/UiTooltips.php b/app/Support/Rbac/UiTooltips.php new file mode 100644 index 0000000..e6fa6d2 --- /dev/null +++ b/app/Support/Rbac/UiTooltips.php @@ -0,0 +1,33 @@ +