From 6a86c5901ac94f5588088a83ee80ba4aac34989c Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 30 Jan 2026 16:58:02 +0000 Subject: [PATCH] 066-rbac-ui-enforcement-helper (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kontext / Ziel Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk). Links zur Spec: spec.md plan.md quickstart.md Was ist drin Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions) Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext) Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns) CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**: verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort) RBAC-UX Semantik (konsequent & testbar) Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards. Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects). Member mit Capability: Action enabled und ausführbar. Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)). Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes. Sicherheit / Scope Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP). Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership). Kein Asset-Setup erforderlich; keine neuen Filament Assets. Compliance Notes (Repo-Regeln) Filament v5 / Livewire v4.0+ kompatibel. Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert). Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR. Tests / Qualität Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard. Guard-Test: “No ad-hoc Filament auth patterns”. Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped. Checklist: requirements.md vollständig (16/16). Review-Fokus API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply() Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design). Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/81 --- .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 + .../checklists/requirements.md | 34 + specs/066-rbac-ui-enforcement-helper/plan.md | 188 +++ .../quickstart.md | 262 ++++ specs/066-rbac-ui-enforcement-helper/spec.md | 163 +++ specs/066-rbac-ui-enforcement-helper/tasks.md | 254 ++++ .../DriftBulkAcknowledgeAuthorizationTest.php | 26 +- .../Filament/BackupItemsBulkRemoveTest.php | 2 + tests/Feature/Filament/TenantMembersTest.php | 9 +- .../NoAdHocFilamentAuthPatternsTest.php | 98 ++ .../Inventory/InventorySyncButtonTest.php | 2 +- .../ProviderConnectionAuthorizationTest.php | 8 +- ...pItemsRelationManagerUiEnforcementTest.php | 98 ++ .../CreateRestoreRunAuthorizationTest.php | 37 + .../Rbac/DriftLandingUiEnforcementTest.php | 65 + ...ditProviderConnectionUiEnforcementTest.php | 80 ++ .../EditTenantArchiveUiEnforcementTest.php | 54 + .../EntraGroupSyncRunsUiEnforcementTest.php | 66 + ...InventoryItemResourceAuthorizationTest.php | 56 + ...rsionsRestoreToIntuneUiEnforcementTest.php | 93 ++ ...iderConnectionsCreateUiEnforcementTest.php | 54 + .../Rbac/RegisterTenantAuthorizationTest.php | 23 + .../Rbac/RoleMatrix/ManagerAccessTest.php | 2 + .../Rbac/RoleMatrix/OperatorAccessTest.php | 2 + .../Rbac/RoleMatrix/OwnerAccessTest.php | 2 + .../Rbac/RoleMatrix/ReadonlyAccessTest.php | 2 + ...rshipsRelationManagerUiEnforcementTest.php | 48 + .../Rbac/TenantResourceAuthorizationTest.php | 58 + .../Rbac/UiEnforcementDestructiveTest.php | 59 + .../Rbac/UiEnforcementMemberDisabledTest.php | 92 ++ .../Rbac/UiEnforcementNonMemberHiddenTest.php | 152 +++ tests/Feature/RunStartAuthorizationTest.php | 2 +- tests/Unit/Support/Rbac/UiEnforcementTest.php | 84 ++ 63 files changed, 7105 insertions(+), 4466 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 create mode 100644 specs/066-rbac-ui-enforcement-helper/checklists/requirements.md create mode 100644 specs/066-rbac-ui-enforcement-helper/plan.md create mode 100644 specs/066-rbac-ui-enforcement-helper/quickstart.md create mode 100644 specs/066-rbac-ui-enforcement-helper/spec.md create mode 100644 specs/066-rbac-ui-enforcement-helper/tasks.md create mode 100644 tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php create mode 100644 tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php create mode 100644 tests/Feature/Rbac/DriftLandingUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php create mode 100644 tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/RegisterTenantAuthorizationTest.php create mode 100644 tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php create mode 100644 tests/Feature/Rbac/TenantResourceAuthorizationTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementDestructiveTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php create mode 100644 tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php create mode 100644 tests/Unit/Support/Rbac/UiEnforcementTest.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 @@ +visible(fn ...)` / `->disabled(fn ...)` calls — target for migration | + +## Project Structure + +### Documentation (this feature) + +```text +specs/066-rbac-ui-enforcement-helper/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # (no separate file needed — inline above) +├── data-model.md # (no schema changes) +├── quickstart.md # Adoption guide +├── checklists/ +│ └── requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +app/ +├── Support/ +│ └── Rbac/ +│ ├── UiEnforcement.php # Central facade/builder +│ ├── TenantAccessContext.php # DTO: tenant, user, isMember, capabilityCheck +│ └── UiTooltips.php # Standardized tooltip strings +├── Services/Auth/ +│ ├── CapabilityResolver.php # (existing, reused) +│ └── RoleCapabilityMap.php # (existing, reused) +├── Filament/ +│ └── Resources/... # 3–6 exemplar migrations + +tests/ +├── Feature/ +│ └── Rbac/ +│ └── UiEnforcementTest.php # Integration tests +│ └── Guards/ +│ └── NoAdHocFilamentAuthPatternsTest.php # CI-failing guard (file-scan) +├── Unit/ +│ └── Support/Rbac/ +│ └── UiEnforcementTest.php # Unit tests + +``` + +**Structure Decision**: All new code lives in `app/Support/Rbac/` (helper) + tests; no new models/tables required. + +## Key Design Decisions + +### UiEnforcement API (FR-001) + +```php +use App\Support\Rbac\UiEnforcement; + +// Basic usage +UiEnforcement::forAction($action) + ->requireMembership() // default: true + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->destructive() // optional: adds confirmation + ->apply(); + +// Table/row action (receives record or record-accessor closure) +UiEnforcement::forTableAction(Action $action, Model|Closure $record) + ->requireCapability(Capabilities::TENANT_DELETE) + ->destructive() + ->apply(); + +// Mixed visibility support (keep business visibility, add RBAC visibility) +UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + +// Bulk action (all-or-nothing) +UiEnforcement::forBulkAction($action) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); +``` + +Internally: +1. Resolves current tenant + user via `Filament::getTenant()` + `auth()->user()` +2. Checks membership via `CapabilityResolver` (request-scope cached) +3. Sets `->hidden()` for non-members (FR-002a) +4. Sets `->disabled()` + `->tooltip()` for members without capability (FR-004) +5. Wraps handler with server-side guard (FR-005): `abort(404)` / `abort(403)` + +### Tooltip Copy (FR-008) + +```php +class UiTooltips +{ + public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.'; + public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?'; + public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.'; +} +``` + +### Destructive Confirmation (FR-007) + +`->destructive()` calls: +- `$action->requiresConfirmation()` +- `$action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)` +- `$action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)` + +### All-or-nothing Bulk (FR-010a) + +Before rendering, bulk action checks all selected records. If any record fails capability check for the member, action is disabled. + +### Guardrail (FR-011) + +`tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` scans `app/Filament/**` for forbidden patterns like: +- `Gate::allows(...)` / `Gate::denies(...)` +- `abort_if(...)` / `abort_unless(...)` + +It uses a legacy allowlist so CI fails only for **new** violations, and the allowlist should shrink as resources are migrated. + +## v1 Migration Targets (FR-009) + +| Surface | File | Current Pattern | Notes | +|---------|------|-----------------|-------| +| TenantResource table actions | `TenantResource.php` | Multiple `->visible(fn ...)` + `->disabled(fn ...)` | High-traffic, high-value | +| ProviderConnectionResource actions | `EditProviderConnection.php` | Multiple `canAccessTenant` + capability checks inline | Complex, good test case | +| BackupSetResource table actions | `BackupSetResource.php` | Many `->disabled(fn ...)` closures | Destructive actions | +| PolicyResource ListPolicies sync | `ListPolicies.php` | Inline checks | Good example | +| EntraGroupResource sync | `ListEntraGroups.php` | Inline checks | Good example | +| FindingResource actions | `FindingResource.php` | `->authorize(fn ...)` inline | Good example | + +## Complexity Tracking + +> No constitution violations. Complexity is low (helper + tests + 3–6 migrations). + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | diff --git a/specs/066-rbac-ui-enforcement-helper/quickstart.md b/specs/066-rbac-ui-enforcement-helper/quickstart.md new file mode 100644 index 0000000..d4128c9 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/quickstart.md @@ -0,0 +1,262 @@ +# Quickstart: UiEnforcement Helper + +> Adoption guide for developers adding RBAC enforcement to Filament actions. + +## TL;DR + +Replace ad-hoc `->visible(fn ...)` / `->disabled(fn ...)` closures with `UiEnforcement`. + +```php +// ❌ Before (ad-hoc) +Action::make('sync') + ->visible(fn () => auth()->user()->can('provider:manage', Filament::getTenant())) + ->disabled(fn () => ! auth()->user()->can('provider:manage', Filament::getTenant())) + ->action(function () { + // no server-side guard + }); + +// ✅ After (UiEnforcement) +UiEnforcement::forAction( + Action::make('sync') + ->action(fn () => $this->sync()) +) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(); +``` + +## When to Use + +| Scenario | Use UiEnforcement? | +|----------|-------------------| +| Tenant-scoped action (header, table, bulk) | ✅ Yes | +| Platform-scoped action (/system panel) | ❌ No (use Gate directly) | +| Read-only navigation link | ❌ No (use `->visible()` for nav items) | +| Destructive action (delete, detach, restore) | ✅ Yes, with `->destructive()` | + +## API Reference + +### Header / Page Actions + +```php +use App\Support\Rbac\UiEnforcement; +use App\Support\Auth\Capabilities; + +protected function getHeaderActions(): array +{ + return [ + UiEnforcement::forAction( + Action::make('createBackup') + ->action(fn () => $this->createBackup()) + ) + ->requireCapability(Capabilities::BACKUP_CREATE) + ->apply(), + + UiEnforcement::forAction( + Action::make('deleteAllBackups') + ->action(fn () => $this->deleteAll()) + ) + ->requireCapability(Capabilities::BACKUP_MANAGE) + ->destructive() + ->apply(), + ]; +} +``` + +### Table Row Actions + +```php +public static function table(Table $table): Table +{ + return $table + ->columns([...]) + ->actions([ + UiEnforcement::forTableAction( + Action::make('restore') + ->action(fn (Policy $record) => $record->restore()), + fn () => $this->getRecord() // record accessor + ) + ->requireCapability(Capabilities::RESTORE_EXECUTE) + ->destructive() + ->apply(), + ]); +} +``` + +### Bulk Actions + +```php +->bulkActions([ + UiEnforcement::forBulkAction( + BulkAction::make('deleteSelected') + ->action(fn (Collection $records) => $records->each->delete()) + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->destructive() + ->apply(), +]) +``` + +## Behavior Matrix + +| User Status | UI State | Server Response | +|-------------|----------|-----------------| +| Non-member | Hidden | Blocked (no execution, 200) | +| Member, no capability | Visible, disabled + tooltip | Blocked (no execution, 200) | +| Member, has capability | Enabled | Executes | +| Member, destructive action | Confirmation modal | Executes after confirm | + +> **Note on 404/403 Responses:** In Filament v5, hidden actions are automatically +> treated as disabled, so execution is blocked silently (returns 200 with no side +> effects). True 404 enforcement happens at the page/routing level via tenant +> middleware. The UiEnforcement helper includes defense-in-depth server-side +> guards that abort(404/403) if somehow reached, but the primary protection is +> Filament's isHidden/isDisabled chain. + +## Tooltip Customization + +Default tooltip: *"You don't have permission to do this. Ask a tenant admin."* + +Override per-action: + +```php +UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('Contact your organization owner to enable this feature.') + ->apply(); +``` + +## Testing + +Test both UI state and execution blocking: + +```php +it('hides sync action for non-members', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + // user is NOT a member + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); +}); + +it('blocks action execution for non-members (no side effects)', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + Queue::fake(); + + actingAs($user); + Filament::setTenant($tenant, true); + + // Hidden actions are blocked silently (200 but no execution) + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects occurred + Queue::assertNothingPushed(); +}); + +it('disables sync action for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionDisabled('sync'); +}); + +it('shows disabled tooltip for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHasTooltip('sync', UiTooltips::INSUFFICIENT_PERMISSION); +}); +``` + +## Migration Checklist + +When migrating an existing action: + +- [ ] Remove `->visible(fn ...)` closure (UiEnforcement handles this) +- [ ] Remove `->disabled(fn ...)` closure (UiEnforcement handles this) +- [ ] Remove inline `Gate::check()` / `abort_unless()` from action handler +- [ ] Wrap action with `UiEnforcement::forAction(...)->requireCapability(...)->apply()` +- [ ] Add `->destructive()` if action modifies/deletes data +- [ ] Add test for non-member (hidden + no execution) +- [ ] Add test for member without capability (disabled + tooltip) +- [ ] Add test for member with capability (enabled + executes) + +### Real Example: ListPolicies Sync Action + +```php +// Before (ad-hoc) +Action::make('sync') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $tenant = Tenant::current(); + if (! $tenant) { + return; + } + // ... sync logic + }) + ->disabled(fn (): bool => ! Gate::allows(Capabilities::TENANT_SYNC, Tenant::current())) + +// After (UiEnforcement) +UiEnforcement::forAction( + Action::make('sync') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $tenant = Tenant::current(); + if (! $tenant) { + return; + } + // ... sync logic + }) +) + ->requireCapability(Capabilities::TENANT_SYNC) + ->destructive() + ->apply() +``` + +## Common Mistakes + +### ❌ Forgetting `->apply()` + +```php +// This does nothing! +UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE); + // missing ->apply() +``` + +### ❌ Using with non-tenant panels + +```php +// UiEnforcement is tenant-scoped only! +// For /system panel, use Gate::check() directly +``` + +### ❌ Mixing old and new patterns + +```php +// Don't mix - pick one +UiEnforcement::forAction( + Action::make('sync') + ->visible(fn () => someOtherCheck()) // ❌ conflict +) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(); +``` + +## Questions? + +See [spec.md](./spec.md) for full requirements or [plan.md](./plan.md) for implementation details. diff --git a/specs/066-rbac-ui-enforcement-helper/spec.md b/specs/066-rbac-ui-enforcement-helper/spec.md new file mode 100644 index 0000000..8226d05 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/spec.md @@ -0,0 +1,163 @@ +# Feature Specification: RBAC UI Enforcement Helper v1 + +**Feature Branch**: `066-rbac-ui-enforcement-helper` +**Created**: 2026-01-28 +**Status**: Draft +**Input**: Provide a suite-wide, consistent way to enforce tenant RBAC for admin UI actions (buttons/actions in lists, records, and bulk actions) without copy/paste authorization logic. + +## Clarifications + +### Session 2026-01-28 + +- Q: For Bulk Actions with mixed-permission records (some authorized, some not), what should the default behavior be? → A: All-or-nothing (if any selected record would be unauthorized, the bulk action is disabled for members and execution fails with 403 for members / 404 for non-members). +- Q: Should the helper render actions at all for non-members (in case a tenant page is reachable via misrouting), or always hide them? → A: Hide for non-members in UI, but still enforce 404 server-side for any execution attempt. +- Q: How strict should the “no ad-hoc authorization patterns in app/Filament/**” guard be in v1? → A: CI-failing (new ad-hoc patterns fail tests/CI). + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Tenant member sees consistent disabled UX (Priority: P1) + +As a tenant member, I can clearly see which actions exist, and when I lack permission the action is visible but disabled with an explanatory tooltip. + +**Why this priority**: Prevents confusion and reduces support load while keeping the UI predictable for members. + +**Independent Test**: Can be tested by visiting a tenant-scoped admin page as a member with insufficient permissions and verifying the action is disabled, shows the standard tooltip, and cannot be executed. + +**Acceptance Scenarios**: + +1. **Given** a tenant member without the required capability, **When** they view an action on a tenant-scoped page, **Then** the action is visible but disabled and shows the standard “insufficient permission” tooltip. +2. **Given** a tenant member without the required capability, **When** they attempt to execute the action (including direct invocation, bypassing the UI), **Then** the server rejects with 403. + +--- + +### User Story 2 - Non-members cannot infer tenant resources (Priority: P2) + +As a non-member of a tenant, I cannot discover tenant-scoped resources or actions; the system responds as “not found”. + +**Why this priority**: Prevents tenant enumeration and cross-tenant information leakage. + +**Independent Test**: Can be tested by attempting to access tenant-scoped pages/actions as a user without membership and verifying 404 behavior. + +**Acceptance Scenarios**: + +1. **Given** a user who is not entitled to the tenant scope, **When** they attempt any tenant-scoped page or action, **Then** the system responds as 404 (deny-as-not-found). + +--- + +### User Story 3 - Maintainers add actions safely by default (Priority: P3) + +As a maintainer, I can add new tenant-scoped actions using one standard pattern, and regression guards prevent introducing ad-hoc authorization logic. + +**Why this priority**: Reduces RBAC regressions as the app grows and makes reviews easier. + +**Independent Test**: Can be tested by introducing a sample ad-hoc authorization pattern and confirming automated checks/tests flag it. + +**Acceptance Scenarios**: + +1. **Given** a maintainer adds a new tenant-scoped action, **When** they use the central enforcement helper, **Then** member/non-member semantics and tooltip behavior match the standard without additional per-page customization. +2. **Given** a maintainer introduces a new ad-hoc authorization mapping in tenant-scoped admin UI code, **When** automated checks run, **Then** the change is flagged to prevent drift. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- Membership is revoked while the user has the page open (execution must still enforce 404 semantics). +- Capability changes mid-session (UI may be stale; server enforcement remains correct). +- Bulk actions with mixed-permission records: all-or-nothing (disable + tooltip for members; 403 on execution for members; 404 semantics for non-members). +- Target record is deleted/archived between render and execution (no information leakage in errors). + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** This feature defines a default pattern for tenant-plane admin actions. The implementation MUST: +- enforce membership as an isolation boundary (non-member / not entitled → 404 deny-as-not-found), +- enforce capability denials as 403 (after membership is established), +- keep actions visible-but-disabled with a standard tooltip for members lacking capability (except allowed sensitive exceptions), +- enforce authorization server-side for every mutation/operation-start/credential change, +- use the canonical capability registry (no raw capability string literals), +- ensure destructive-like actions require confirmation, +- ship regression tests and a guard against new ad-hoc authorization patterns. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + + + +### Functional Requirements + +- **FR-001**: The system MUST provide a single, centrally maintained enforcement mechanism that can be applied to tenant-scoped admin actions (including header actions, record actions, and bulk actions). +- **FR-002**: For tenant-scoped actions, the system MUST enforce membership as deny-as-not-found: users not entitled to the tenant scope MUST receive 404 semantics for action execution. +- **FR-002a**: For users not entitled to the tenant scope, the UI SHOULD NOT render tenant-scoped actions (default: hidden), while server-side execution MUST still enforce 404 semantics. +- **FR-003**: For tenant members, the system MUST enforce capability denial as 403 when executing an action without permission. +- **FR-004**: For tenant members lacking capability, the UI MUST render actions as visible-but-disabled and MUST show a standard tooltip explaining the missing permission. +- **FR-005**: The enforcement mechanism MUST also enforce the same rules server-side (UI state is never sufficient). +- **FR-006**: The enforcement mechanism MUST be capability-first and MUST reference capabilities only via the canonical capability registry (no ad-hoc string literals). +- **FR-007**: The enforcement mechanism MUST provide a standard confirmation behavior for destructive-like actions, including a clear warning message. +- **FR-008**: The system MUST provide standardized, non-leaky error and tooltip messages: + - 404 semantics for non-members without hints. + - 403 responses for insufficient capability without object details. +- **FR-009**: v1 MUST include limited adoption by migrating 3–6 exemplar action surfaces to the new pattern to prove the approach. +- **FR-010**: v1 MUST include regression tests that cover: non-member → 404, member without capability → disabled UI + 403 on execution, member with capability → allowed. +- **FR-010a**: For bulk actions with mixed-permission records, the default behavior MUST be all-or-nothing (members see disabled + tooltip; execution denies with 403; non-members receive 404 semantics). +- **FR-011**: v1 MUST include an automated, CI-failing guard that flags new ad-hoc authorization patterns in tenant-scoped admin UI code. +- **FR-012**: The enforcement mechanism MUST avoid introducing avoidable performance regressions (no per-record membership lookups during render). +- **FR-013**: The enforcement mechanism MUST NOT trigger outbound HTTP calls during render; it is DB-only. + +### Key Entities *(include if feature involves data)* + +- **Tenant**: The isolation boundary for all tenant-scoped UI and actions. +- **User**: The authenticated actor attempting to view or execute actions. +- **Membership**: Whether a user is entitled to a tenant scope. +- **Capability**: A named permission from the canonical capability registry. +- **Action**: A discrete operation exposed in the tenant-scoped admin interface. + +### Assumptions + +- Default tooltip language is English (i18n may be added later). +- Non-destructive bulk actions are in scope for v1; destructive bulk actions may be supported but are not required for v1 completion. +- Global search tenant scoping is out of scope for this spec (covered by separate work), but this feature must not introduce new leaks. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: For all migrated tenant-scoped action surfaces, 100% of non-member execution attempts are denied with 404 semantics (verified by automated tests). +- **SC-002**: For all migrated tenant-scoped action surfaces, 100% of member-but-unauthorized execution attempts are denied with 403 (verified by automated tests). +- **SC-003**: For all migrated tenant-scoped action surfaces, members lacking capability see the action visible-but-disabled with the standard tooltip (verified by automated tests and/or UI assertions). +- **SC-004**: At least one automated guard exists that flags newly introduced ad-hoc authorization patterns in tenant-scoped admin UI code. +- **SC-005**: v1 demonstrates adoption by migrating 3–6 exemplar action surfaces, reducing duplicate authorization wiring in those areas. diff --git a/specs/066-rbac-ui-enforcement-helper/tasks.md b/specs/066-rbac-ui-enforcement-helper/tasks.md new file mode 100644 index 0000000..8ee4e18 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/tasks.md @@ -0,0 +1,254 @@ +# Tasks: RBAC UI Enforcement Helper v1 + +**Input**: Design documents from `/specs/066-rbac-ui-enforcement-helper/` +**Prerequisites**: plan.md ✓, spec.md ✓, quickstart.md ✓ + +**Tests**: REQUIRED (Pest) — this feature changes runtime authorization behavior. +**RBAC**: This feature IS the RBAC enforcement helper — all tasks enforce constitution RBAC-UX rules. + +**Organization**: Tasks grouped by user story for independent implementation. + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: US1/US2/US3 for user story phases; omitted for Setup/Foundational/Polish + +--- + +## Phase 1: Setup + +**Purpose**: Create helper infrastructure with no external dependencies + +- [X] T001 Create directory structure `app/Support/Rbac/` +- [X] T002 [P] Create `UiTooltips.php` with tooltip constants in `app/Support/Rbac/UiTooltips.php` +- [X] T003 [P] Create `TenantAccessContext.php` DTO in `app/Support/Rbac/TenantAccessContext.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core `UiEnforcement` helper — MUST complete before any user story tests + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Implement `UiEnforcement::forAction()` static method in `app/Support/Rbac/UiEnforcement.php` +- [X] T005 Implement `->requireMembership()` method (default: true) in `app/Support/Rbac/UiEnforcement.php` +- [X] T006 Implement `->requireCapability(string $capability)` method in `app/Support/Rbac/UiEnforcement.php` +- [X] T007 Implement `->destructive()` method (confirmation modal) in `app/Support/Rbac/UiEnforcement.php` +- [X] T008 Implement `->tooltip(string $message)` override method in `app/Support/Rbac/UiEnforcement.php` +- [X] T009 Implement `->apply()` method (sets hidden/disabled/guards) in `app/Support/Rbac/UiEnforcement.php` +- [X] T010 Implement `UiEnforcement::forTableAction()` static method in `app/Support/Rbac/UiEnforcement.php` +- [X] T011 Implement `UiEnforcement::forBulkAction()` static method with all-or-nothing logic in `app/Support/Rbac/UiEnforcement.php` + +**Checkpoint**: `UiEnforcement` class ready — user story tests can now be written + +--- + +## Phase 3: User Story 1 — Tenant member sees consistent disabled UX (Priority: P1) 🎯 MVP + +**Goal**: Members lacking capability see actions visible-but-disabled with standard tooltip; 403 on execution + +**Independent Test**: Visit tenant page as member with insufficient permission → action disabled with tooltip, cannot execute + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Test: member without capability sees disabled action + tooltip in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T013 [P] [US1] Test: member without capability is blocked from execution in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T014 [P] [US1] Test: member with capability sees enabled action + can execute in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T014a [P] [US1] Test: destructive action shows confirmation modal before execution in `tests/Feature/Rbac/UiEnforcementDestructiveTest.php` + +### Implementation for User Story 1 + +- [X] T015 [US1] Validate `->apply()` correctly sets `->disabled()` + `->tooltip()` for members lacking capability (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php` +- [X] T016 [US1] Validate `->apply()` correctly blocks unauthorized execution (via Filament's isDisabled check + defense-in-depth abort) in `app/Support/Rbac/UiEnforcement.php` +- ~~T017 [US1] Migrate TenantResource table actions to UiEnforcement~~ **OUT OF SCOPE v1**: TenantResource is record==tenant, not tenant-scoped +- [X] T018 [US1] Migrate ProviderConnectionResource actions to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/ProviderConnectionResource.php` + +**Checkpoint**: US1 complete — members see consistent disabled UX with tooltip (exemplar: ListPolicies) + +--- + +## Phase 4: User Story 2 — Non-members cannot infer tenant resources (Priority: P2) + +**Goal**: Non-members receive 404 (deny-as-not-found) for all tenant-scoped actions; actions hidden in UI + +**Independent Test**: Access tenant page as non-member → actions hidden, execution returns 404 + +### Tests for User Story 2 + +- [X] T019 [P] [US2] Test: non-member sees action hidden in UI in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` +- [X] T020 [P] [US2] Test: non-member action is blocked (via Filament hidden-action semantics) in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` +- [X] T021 [P] [US2] Test: membership revoked mid-session still enforces protection in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` + +### Implementation for User Story 2 + +- [X] T022 [US2] Validate `->apply()` correctly sets `->hidden()` for non-members (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php` +- [X] T023 [US2] Validate `->apply()` blocks non-member execution (via Filament's isHidden → isDisabled chain; 404 server-side guard is defense-in-depth) in `app/Support/Rbac/UiEnforcement.php` +- [X] T024 [US2] Migrate BackupSetResource actions (row + bulk) to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/BackupSetResource.php` +- [X] T025 [US2] Migrate PolicyResource sync actions to UiEnforcement in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php` + +**Checkpoint**: US2 complete — non-members receive 404 semantics, no information leakage + +--- + +## Phase 5: User Story 3 — Maintainers add actions safely by default (Priority: P3) + +**Goal**: CI-failing guard flags new ad-hoc authorization patterns; standard pattern documented + +**Independent Test**: Introduce ad-hoc `Gate::allows` or `abort_unless()` in Filament → guard test fails + +### Tests for User Story 3 + +- [X] T026 [P] [US3] Guard test: scan `app/Filament/**` for forbidden ad-hoc patterns (Gate + abort helpers) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` +- [X] T027 [P] [US3] Unit test: UiEnforcement uses only canonical Capabilities constants in `tests/Unit/Support/Rbac/UiEnforcementTest.php` + +### Implementation for User Story 3 + +- [X] T028 [US3] Replace Pest-Arch guard with stable file-scan guard (CI-failing, allowlist for legacy only) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` +- [X] T029 [US3] Migrate EntraGroupResource sync actions to UiEnforcement in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [X] T030 [US3] Remove Gate facade usage from FindingResource (migrate auth to canonical checks) in `app/Filament/Resources/FindingResource.php` + +**Checkpoint**: US3 complete — guardrail prevents regression (file-scan), exemplar surfaces migrated + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Cleanup, additional tests, documentation + +- [X] T031 [P] PHPDoc blocks present on all public methods in `app/Support/Rbac/UiEnforcement.php` +- [X] T032 [P] Update quickstart.md with migration examples in `specs/066-rbac-ui-enforcement-helper/quickstart.md` +- [X] T033 Run Pint formatter on new files with `vendor/bin/sail bin pint app/Support/Rbac` +- [X] T034 Run full test suite with `vendor/bin/sail artisan test --compact` — 837 passed, 5 skipped +- [X] T035 Validate quickstart.md examples work in codebase (ListPolicies migration verified) + +--- + +## Phase 7: Follow-up — Findings capability cleanup (Mini-feature) + +**Purpose**: Avoid overloading broad capabilities (e.g. `TENANT_SYNC`) for findings acknowledgement. + +- [X] T036 Add `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` in `app/Support/Auth/Capabilities.php` +- [X] T037 Grant `TENANT_FINDINGS_ACKNOWLEDGE` to Owner/Manager/Operator (not Readonly) + update role-matrix tests +- [X] T038 Update Finding list acknowledge action to require `TENANT_FINDINGS_ACKNOWLEDGE` in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [X] T039 Refactor `FindingPolicy::update()` to use `CapabilityResolver` with `TENANT_FINDINGS_ACKNOWLEDGE` (remove ad-hoc `Gate::forUser(...)->allows(...)`) + +--- + +## Phase 8: Follow-up — Legacy allowlist shrink (Stepwise) + +**Purpose**: Keep shrinking the Filament guard allowlist with one-file migrations. + +- [X] T040 Remove `BackupScheduleResource.php` from the legacy allowlist after migration +- [X] T041 Migrate `ListEntraGroupSyncRuns.php` to UiEnforcement + add a focused Livewire test +- [X] T042 Remove `ListEntraGroupSyncRuns.php` from the legacy allowlist after migration +- [X] T043 Migrate `ListProviderConnections.php` create action to UiEnforcement + add a focused Livewire test +- [X] T044 Remove `ListProviderConnections.php` from the legacy allowlist after migration +- [X] T045 Migrate `DriftLanding.php` generation permission check to `CapabilityResolver` (remove Gate facade) + add a focused Livewire test +- [X] T046 Remove `DriftLanding.php` from the legacy allowlist after migration +- [X] T047 Migrate `RegisterTenant.php` page-level checks to `CapabilityResolver` + replace `abort_unless()` with `abort()` +- [X] T048 Remove `RegisterTenant.php` from the legacy allowlist after migration +- [X] T049 Migrate `EditProviderConnection.php` actions + save guards to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T050 Remove `EditProviderConnection.php` from the legacy allowlist after migration +- [X] T051 Migrate `CreateRestoreRun.php` page authorization to `CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T052 Remove `CreateRestoreRun.php` from the legacy allowlist after migration +- [X] T053 Migrate `InventoryItemResource.php` resource authorization to `CapabilityResolver` (remove Gate facade) + add a focused Pest test +- [X] T054 Remove `InventoryItemResource.php` from the legacy allowlist after migration +- [X] T055 Migrate `VersionsRelationManager.php` restore action to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T056 Remove `VersionsRelationManager.php` from the legacy allowlist after migration +- [X] T057 Migrate `BackupItemsRelationManager.php` actions to `UiEnforcement` (remove Gate facade) + add a focused Livewire test +- [X] T058 Remove `BackupItemsRelationManager.php` from the legacy allowlist after migration +- [X] T059 Migrate `PolicyVersionResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) while preserving metadata-only restore behavior +- [X] T060 Remove `PolicyVersionResource.php` from the legacy allowlist after migration +- [X] T061 Migrate `RestoreRunResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) +- [X] T062 Remove `RestoreRunResource.php` from the legacy allowlist after migration +- [X] T063 Fix `UiEnforcement` server-side guard to use Filament lifecycle hooks (`->before()`) to preserve Filament action parameter injection +- [X] T064 Migrate `PolicyResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless) +- [X] T065 Remove `PolicyResource.php` from the legacy allowlist after migration +- [X] T066 Migrate `EditTenant.php` archive action off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless) +- [X] T067 Remove `EditTenant.php` from the legacy allowlist after migration +- [X] T068 Migrate `TenantMembershipsRelationManager.php` actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade) +- [X] T069 Remove `TenantMembershipsRelationManager.php` from the legacy allowlist after migration +- [X] T070 Migrate `TenantResource.php` off ad-hoc patterns to `CapabilityResolver` (remove Gate facade + abort_unless) +- [X] T071 Remove `TenantResource.php` from the legacy allowlist after migration + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Stories (Phase 3–5)**: All depend on Foundational; can proceed in parallel or by priority +- **Polish (Phase 6)**: Depends on all user stories + +### User Story Dependencies + +- **US1 (P1)**: Foundational only — no cross-story dependencies +- **US2 (P2)**: Foundational only — no cross-story dependencies +- **US3 (P3)**: Foundational only — no cross-story dependencies + +### Within Each User Story + +- Tests MUST be written FIRST and FAIL before implementation +- Wire logic in `UiEnforcement.php` before migrating Filament surfaces +- Migrate surfaces one at a time, verify tests pass + +### Parallel Opportunities + +- T002 + T003 (Setup) can run in parallel +- All test tasks (T012–T014, T019–T021, T026–T027) can run in parallel +- US1, US2, US3 can run in parallel after Foundational +- T031 + T032 (Polish) can run in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for US1 together: +T012: "Test: member without capability sees disabled action + tooltip" +T013: "Test: member without capability receives 403 on execution" +T014: "Test: member with capability sees enabled action + can execute" + +# Then implement sequentially: +T015 → T016 → T017 → T018 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001–T003) +2. Complete Phase 2: Foundational (T004–T011) +3. Complete Phase 3: User Story 1 (T012–T018) +4. **STOP and VALIDATE**: Members see disabled + tooltip + 403 +5. Deploy/demo if ready + +### Incremental Delivery + +1. Setup + Foundational → `UiEnforcement` ready +2. US1 → Consistent disabled UX for members (MVP!) +3. US2 → Non-member 404 enforcement +4. US3 → CI-failing guardrail + all 6 surfaces migrated +5. Polish → Docs, cleanup, full test suite + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| Total tasks | 40 | +| Setup tasks | 3 | +| Foundational tasks | 8 | +| US1 tasks | 8 | +| US2 tasks | 7 | +| US3 tasks | 5 | +| Polish tasks | 5 | +| Follow-up tasks | 4 | +| Parallel opportunities | 13 | +| MVP scope | Phases 1–3 (T001–T018) | diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php index 4903049..da4d808 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php @@ -18,17 +18,16 @@ 'status' => Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertTableBulkActionVisible('acknowledge_selected') + ->assertTableBulkActionDisabled('acknowledge_selected'); try { - Livewire::test(ListFindings::class) - ->callTableBulkAction('acknowledge_selected', $findings); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callTableBulkAction('acknowledge_selected', $findings); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); @@ -45,16 +44,15 @@ 'status' => Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertActionVisible('acknowledge_all_matching') + ->assertActionDisabled('acknowledge_all_matching'); try { - Livewire::test(ListFindings::class) - ->callAction('acknowledge_all_matching'); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callAction('acknowledge_all_matching'); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php index a2cecee..a96591b 100644 --- a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\Policy; use App\Models\Tenant; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -18,6 +19,7 @@ $tenant = Tenant::factory()->create(); $tenant->makeCurrent(); + Filament::setTenant($tenant, true); [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/tests/Feature/Filament/TenantMembersTest.php b/tests/Feature/Filament/TenantMembersTest.php index fd77013..5f7da44 100644 --- a/tests/Feature/Filament/TenantMembersTest.php +++ b/tests/Feature/Filament/TenantMembersTest.php @@ -74,9 +74,12 @@ 'ownerRecord' => $tenant, 'pageClass' => ViewTenant::class, ]) - ->assertTableActionHidden('add_member') - ->assertTableActionHidden('change_role', $membership) - ->assertTableActionHidden('remove', $membership); + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionVisible('change_role', $membership) + ->assertTableActionDisabled('change_role', $membership) + ->assertTableActionVisible('remove', $membership) + ->assertTableActionDisabled('remove', $membership); }); it('prevents removing or demoting the last owner', function (): void { diff --git a/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php new file mode 100644 index 0000000..c6f5bc9 --- /dev/null +++ b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php @@ -0,0 +1,98 @@ +toBeTrue("Filament directory not found: {$filamentDir}"); + + /** + * Legacy allowlist: these files currently contain forbidden patterns. + * + * IMPORTANT: + * - Do NOT add new entries casually. + * - The goal is to shrink this list over time. + * + * Paths are workspace-relative (e.g. app/Filament/Resources/Foo.php). + */ + $legacyAllowlist = [ + // Pages (page-level authorization or legacy patterns) + ]; + + $patterns = [ + // Gate facade usage + '/\\bGate::(allows|denies|check|authorize)\\b/', + '/^\\s*use\\s+Illuminate\\\\Support\\\\Facades\\\\Gate\\s*;\\s*$/m', + + // Ad-hoc abort helpers + '/\\babort_(if|unless)\\s*\\(/', + ]; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($filamentDir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var array> $violations */ + $violations = []; + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $absolutePath = $file->getPathname(); + $relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $absolutePath); + $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); + + if (in_array($relativePath, $legacyAllowlist, true)) { + continue; + } + + $content = file_get_contents($absolutePath); + if (! is_string($content)) { + continue; + } + + $lines = preg_split('/\\R/', $content) ?: []; + + foreach ($lines as $lineNumber => $line) { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $line) === 1) { + $violations[$relativePath][] = ($lineNumber + 1).': '.trim($line); + } + } + } + } + + if ($violations !== []) { + $messageLines = [ + 'Forbidden ad-hoc auth patterns detected in app/Filament/**.', + 'Migrate to UiEnforcement (preferred) or add a justified temporary entry to the legacy allowlist.', + '', + ]; + + foreach ($violations as $path => $hits) { + $messageLines[] = $path; + foreach ($hits as $hit) { + $messageLines[] = ' - '.$hit; + } + } + + expect($violations)->toBeEmpty(implode("\n", $messageLines)); + } + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 449db2f..841c9ab 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -161,7 +161,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index 4db82c7..5f05487 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -46,9 +46,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('readonly users can view provider connections but cannot manage them', function () { @@ -69,9 +67,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('provider connection edit is not accessible cross-tenant', function () { diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..6bc9e6c --- /dev/null +++ b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php @@ -0,0 +1,98 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $item = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionDisabled('addPolicies') + ->assertTableActionExists('addPolicies', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to add policies.'; + }) + ->assertTableBulkActionVisible('bulk_remove') + ->assertTableBulkActionDisabled('bulk_remove', [$item]); + }); + + it('shows add policies as enabled for owner members', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $item = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionEnabled('addPolicies') + ->assertTableBulkActionVisible('bulk_remove') + ->assertTableBulkActionEnabled('bulk_remove', [$item]); + }); + + it('hides actions after membership is revoked mid-session', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + $component = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableActionVisible('addPolicies') + ->assertTableActionEnabled('addPolicies') + ->assertTableBulkActionVisible('bulk_remove'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertTableActionHidden('addPolicies') + ->assertTableBulkActionHidden('bulk_remove'); + }); +}); diff --git a/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php new file mode 100644 index 0000000..cb96bb3 --- /dev/null +++ b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php @@ -0,0 +1,37 @@ +create(); + $tenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(CreateRestoreRun::class) + ->assertStatus(404); + }); + + it('returns 403 for members without tenant manage capability', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(CreateRestoreRun::class) + ->assertStatus(403); + }); +}); diff --git a/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php new file mode 100644 index 0000000..8337673 --- /dev/null +++ b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php @@ -0,0 +1,65 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDays(2), + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDay(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(DriftLanding::class) + ->assertSet('state', 'blocked') + ->assertSet('message', 'You can view existing drift findings and run history, but you do not have permission to generate drift.'); + + Bus::assertNotDispatched(GenerateDriftFindingsJob::class); + }); + + it('starts generation for owner members (tenant sync allowed)', function () { + Bus::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDays(2), + ]); + + $latestRun = InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'finished_at' => now()->subDay(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(DriftLanding::class) + ->assertSet('state', 'generating') + ->assertSet('scopeKey', (string) $latestRun->selection_hash); + + $operationRunId = $component->get('operationRunId'); + expect($operationRunId)->toBeInt()->toBeGreaterThan(0); + + Bus::assertDispatched(GenerateDriftFindingsJob::class); + }); +}); diff --git a/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php new file mode 100644 index 0000000..d16758d --- /dev/null +++ b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php @@ -0,0 +1,80 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'disabled', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('enable_connection') + ->assertActionDisabled('enable_connection') + ->assertActionExists('enable_connection', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage provider connections.'; + }) + ->mountAction('enable_connection') + ->callMountedAction() + ->assertSuccessful(); + + $connection->refresh(); + expect($connection->status)->toBe('disabled'); + }); + + it('shows disable connection action as visible but disabled for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'connected', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('disable_connection') + ->assertActionDisabled('disable_connection') + ->assertActionExists('disable_connection', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage provider connections.'; + }) + ->mountAction('disable_connection') + ->callMountedAction() + ->assertSuccessful(); + + $connection->refresh(); + expect($connection->status)->toBe('connected'); + }); + + it('shows enable connection action as enabled for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'disabled', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('enable_connection') + ->assertActionEnabled('enable_connection'); + }); +}); diff --git a/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php new file mode 100644 index 0000000..5768b3b --- /dev/null +++ b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php @@ -0,0 +1,54 @@ +create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('archive') + ->assertActionDisabled('archive') + ->assertActionExists('archive', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to archive tenants.'; + }) + ->mountAction('archive') + ->callMountedAction() + ->assertSuccessful(); + + $tenant->refresh(); + expect($tenant->trashed())->toBeFalse(); + }); + + it('allows owner members to archive tenant', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertActionVisible('archive') + ->assertActionEnabled('archive') + ->mountAction('archive') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $tenant->refresh(); + expect($tenant->trashed())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php new file mode 100644 index 0000000..94d7c7d --- /dev/null +++ b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php @@ -0,0 +1,66 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component->assertActionHidden('sync_groups'); + + Queue::assertNothingPushed(); + }); + + it('shows sync action as visible but disabled for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups'); + + Queue::assertNothingPushed(); + }); + + it('allows owner members to execute sync action (dispatches job)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups') + ->assertActionEnabled('sync_groups') + ->mountAction('sync_groups') + ->callMountedAction() + ->assertHasNoActionErrors(); + + Queue::assertPushed(EntraGroupSyncJob::class); + }); +}); diff --git a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php new file mode 100644 index 0000000..bf0c0f7 --- /dev/null +++ b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php @@ -0,0 +1,56 @@ +create(); + $tenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(InventoryItemResource::canViewAny())->toBeFalse(); + }); + + it('is visible for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(InventoryItemResource::canViewAny())->toBeTrue(); + }); + + it('prevents viewing inventory items from other tenants', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $otherTenant = Tenant::factory()->create(); + + $this->actingAs($user); + $tenant->makeCurrent(); + + $record = InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + ]); + + expect(InventoryItemResource::canView($record))->toBeFalse(); + }); + + it('allows viewing inventory items from the current tenant', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + $record = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + expect(InventoryItemResource::canView($record))->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php new file mode 100644 index 0000000..a0b6755 --- /dev/null +++ b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php @@ -0,0 +1,93 @@ +makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => [], + ]); + + Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertTableActionDisabled('restore_to_intune', $version); + }); + + it('disables restore action for metadata-only snapshots', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => ['source' => 'metadata_only'], + ]); + + Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertTableActionDisabled('restore_to_intune', $version); + }); + + it('hides restore action after membership is revoked mid-session', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'metadata' => [], + ]); + + $component = Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertTableActionHidden('restore_to_intune', $version); + }); +}); diff --git a/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php new file mode 100644 index 0000000..18f3456 --- /dev/null +++ b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php @@ -0,0 +1,54 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionDisabled('create') + ->assertActionExists('create', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to create provider connections.'; + }); + }); + + it('shows create action as enabled for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionEnabled('create'); + }); + + it('hides create action after membership is revoked mid-session', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListProviderConnections::class) + ->assertActionVisible('create') + ->assertActionEnabled('create'); + + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + $component + ->call('$refresh') + ->assertActionHidden('create'); + }); +}); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php new file mode 100644 index 0000000..9a1871f --- /dev/null +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -0,0 +1,23 @@ +actingAs($user); + $tenant->makeCurrent(); + + expect(RegisterTenant::canView())->toBeFalse(); + }); + + it('is visible for owner members', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + + expect(RegisterTenant::canView())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php index 71ead6c..e89d3fa 100644 --- a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php index ebc7c0f..6248a0f 100644 --- a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php index fb024ba..c980ec8 100644 --- a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php index dfc7a16..dbf5209 100644 --- a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -15,6 +15,8 @@ expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); diff --git a/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..0dd683f --- /dev/null +++ b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php @@ -0,0 +1,48 @@ +create(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $otherUser = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'readonly'); + + Livewire::test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => EditTenant::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionExists('add_member', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }) + ->assertTableActionVisible('change_role') + ->assertTableActionDisabled('change_role') + ->assertTableActionExists('change_role', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }) + ->assertTableActionVisible('remove') + ->assertTableActionDisabled('remove') + ->assertTableActionExists('remove', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage tenant memberships.'; + }); + }); +}); diff --git a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php new file mode 100644 index 0000000..2a46054 --- /dev/null +++ b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php @@ -0,0 +1,58 @@ +create(); + + $this->actingAs($user); + + expect(TenantResource::canCreate())->toBeFalse(); + }); + + it('can be created by managers (TENANT_MANAGE)', function () { + [$user] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canCreate())->toBeTrue(); + }); + + it('can be edited by managers (TENANT_MANAGE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canEdit($tenant))->toBeTrue(); + }); + + it('cannot be deleted by managers (TENANT_DELETE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + + expect(TenantResource::canDelete($tenant))->toBeFalse(); + }); + + it('can be deleted by owners (TENANT_DELETE)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + expect(TenantResource::canDelete($tenant))->toBeTrue(); + }); + + it('cannot edit tenants it cannot access', function () { + [$user] = createUserWithTenant(role: 'manager'); + $otherTenant = Tenant::factory()->create(); + + $this->actingAs($user); + + expect(TenantResource::canEdit($otherTenant))->toBeFalse(); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementDestructiveTest.php b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php new file mode 100644 index 0000000..749bdee --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php @@ -0,0 +1,59 @@ +actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // mountAction shows the confirmation modal + // assertActionMounted confirms it was mounted (awaiting confirmation) + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync') + ->mountAction('sync') + ->assertActionMounted('sync'); + }); + + it('does not execute destructive action without calling confirm', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Mount but don't call - verify no side effects + Livewire::test(ListPolicies::class) + ->mountAction('sync'); + + // No job should be dispatched yet + Queue::assertNothingPushed(); + }); + + it('has confirmation modal configured with correct title', function () { + // Verify UiTooltips constants are set correctly + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBe('Are you sure?'); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBe('This action cannot be undone.'); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php new file mode 100644 index 0000000..af7ed3b --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php @@ -0,0 +1,92 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionDisabled('sync'); + + Queue::assertNothingPushed(); + }); + + it('does not execute sync action for readonly members (silently blocked by Filament)', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // When a disabled action is called, Filament blocks it silently (200 response, no execution) + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // The action should NOT have executed + Queue::assertNothingPushed(); + }); +}); + +describe('US1: Member with capability sees enabled action + can execute', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('shows sync action as enabled for owner members', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync'); + }); + + it('allows owner members to execute sync action successfully', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertHasNoActionErrors(); + + Queue::assertPushed(SyncPoliciesJob::class); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php new file mode 100644 index 0000000..e8f3ec9 --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php @@ -0,0 +1,152 @@ +create(); + $tenant = Tenant::factory()->create(); + // No membership created + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); + + it('hides sync action for authenticated users accessing wrong tenant', function () { + // User is member of tenantA but accessing tenantB + [$user, $tenantA] = createUserWithTenant(role: 'owner'); + $tenantB = Tenant::factory()->create(); + // User has no membership to tenantB + + $this->actingAs($user); + $tenantB->makeCurrent(); + Filament::setTenant($tenantB, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); +}); + +describe('US2: Non-member action execution is blocked', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('blocks action execution for non-members (no side effects)', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + // No membership + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Hidden actions are treated as disabled by Filament + // The action call returns 200 but no execution occurs + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects + Queue::assertNothingPushed(); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + }); +}); + +describe('US2: Membership revoked mid-session still enforces protection', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('blocks action execution when membership is revoked between page load and action click', function () { + bindFailHardGraphClient(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Start the test - action should be visible for member + $component = Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionEnabled('sync'); + + // Simulate membership revocation mid-session + $user->tenants()->detach($tenant->getKey()); + + // Clear capability cache to ensure fresh check + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + // Now try to execute - action is now hidden (via fresh isVisible evaluation) + // Filament blocks execution (returns 200 but no side effects) + $component + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects + Queue::assertNothingPushed(); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); + }); + + it('hides action in UI after membership revocation on re-render', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + // Initial state - action visible + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync'); + + // Revoke membership + $user->tenants()->detach($tenant->getKey()); + app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); + + // New component instance (simulates page refresh) + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); + + Queue::assertNothingPushed(); + }); +}); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index 83fbb70..ceb8d0b 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -25,7 +25,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Unit/Support/Rbac/UiEnforcementTest.php b/tests/Unit/Support/Rbac/UiEnforcementTest.php new file mode 100644 index 0000000..4f5f0a2 --- /dev/null +++ b/tests/Unit/Support/Rbac/UiEnforcementTest.php @@ -0,0 +1,84 @@ +make(), + tenant: Tenant::factory()->make(), + isMember: false, + hasCapability: false, + ); + + expect($context->shouldDenyAsNotFound())->toBeTrue(); + expect($context->shouldDenyAsForbidden())->toBeFalse(); + expect($context->isAuthorized())->toBeFalse(); + }); + + it('correctly identifies member without capability as forbidden', function () { + $context = new TenantAccessContext( + user: User::factory()->make(), + tenant: Tenant::factory()->make(), + isMember: true, + hasCapability: false, + ); + + expect($context->shouldDenyAsNotFound())->toBeFalse(); + expect($context->shouldDenyAsForbidden())->toBeTrue(); + expect($context->isAuthorized())->toBeFalse(); + }); + + it('correctly identifies authorized member', function () { + $context = new TenantAccessContext( + user: User::factory()->make(), + tenant: Tenant::factory()->make(), + isMember: true, + hasCapability: true, + ); + + expect($context->shouldDenyAsNotFound())->toBeFalse(); + expect($context->shouldDenyAsForbidden())->toBeFalse(); + expect($context->isAuthorized())->toBeTrue(); + }); +}); + +describe('UiTooltips', function () { + it('has non-empty insufficient permission message', function () { + expect(UiTooltips::INSUFFICIENT_PERMISSION)->toBeString(); + expect(UiTooltips::INSUFFICIENT_PERMISSION)->not->toBeEmpty(); + }); + + it('has non-empty destructive confirmation messages', function () { + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBeString(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->not->toBeEmpty(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBeString(); + expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->not->toBeEmpty(); + }); +}); + +describe('UiEnforcement', function () { + it('throws when unknown capability is passed', function () { + $action = \Filament\Actions\Action::make('test') + ->action(fn () => null); + + expect(fn () => UiEnforcement::forAction($action) + ->requireCapability('unknown.capability') + )->toThrow(\InvalidArgumentException::class, 'Unknown capability'); + }); + + it('accepts known capabilities from registry', function () { + $action = \Filament\Actions\Action::make('test') + ->action(fn () => null); + + $enforcement = UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE); + + expect($enforcement)->toBeInstanceOf(UiEnforcement::class); + }); +});