diff --git a/Agents.md b/Agents.md index 7d3d8d6..c546c58 100644 --- a/Agents.md +++ b/Agents.md @@ -1056,9 +1056,9 @@ ### Replaced Utilities ## Active Technologies -- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416) -- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416) -- PHP 8.4.15 (Laravel 12) + Filament v5 + Livewire v4 (059-unified-badges) +- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4 +- PostgreSQL (Sail) +- Tailwind CSS v4 ## Recent Changes -- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 +- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts) diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index ecb62ff..068f781 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -15,6 +15,7 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -34,7 +35,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 +47,7 @@ class BackupSetResource extends Resource public static function canCreate(): bool { - return ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_SYNC, $tenant); + return UiEnforcement::for(Capabilities::TENANT_SYNC)->isAllowed(); } public static function form(Schema $schema): Schema @@ -90,123 +89,116 @@ 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::for(Capabilities::TENANT_MANAGE) + ->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false) + ->apply( + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - 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(); + }), + ), - 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(); + UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->andVisibleWhen(fn (?BackupSet $record): bool => $record ? ! $record->trashed() : false) + ->apply( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->delete(); - $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]] + ); + } - 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(); + }), + ), - 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(); + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false) + ->apply( + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); - 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() + ->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; + } - 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]] + ); + } - 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(); - $record->items()->withTrashed()->forceDelete(); - $record->forceDelete(); - - Notification::make() - ->title('Backup set permanently deleted') - ->success() - ->send(); - }), + Notification::make() + ->title('Backup set permanently deleted') + ->success() + ->send(); + }), + ), ])->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 { + UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->andHiddenWhen(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -214,83 +206,80 @@ public static function table(Table $table): Table 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.', - ]), - ]; - } + ->apply( + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->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) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - if (! $tenant instanceof Tenant) { - return; - } + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - 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, + $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, ); - }, - 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(), + OperationUxPresenter::queuedToast('backup_set.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ), - 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 { + UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->andHiddenWhen(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -298,69 +287,66 @@ public static function table(Table $table): Table 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(); + ->apply( + BulkAction::make('bulk_restore') + ->label('Restore Backup Sets') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->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) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - if (! $tenant instanceof Tenant) { - return; - } + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - 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.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, + $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, ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); - OperationUxPresenter::queuedToast('backup_set.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + OperationUxPresenter::queuedToast('backup_set.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ), - 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 { + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->andHiddenWhen(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -368,75 +354,78 @@ public static function table(Table $table): Table 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.', - ]), - ]; - } + ->apply( + BulkAction::make('bulk_force_delete') + ->label('Force Delete Backup Sets') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->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.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + return []; + }) + ->action(function (Collection $records) { + UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); - if (! $tenant instanceof Tenant) { - return; - } + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $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.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, + $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, ); - }, - 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(), + ), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php index 893ed9f..afd21e5 100644 --- a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php +++ b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php @@ -3,6 +3,8 @@ namespace App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource; +use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -13,9 +15,7 @@ class ListBackupSets extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() - ->disabled(fn (): bool => ! BackupSetResource::canCreate()) - ->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'), + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()), ]; } } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 05b7486..737b890 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -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 { @@ -131,23 +131,21 @@ public function table(Table $table): Table ->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(); + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( + Actions\Action::make('addPolicies') + ->label('Add Policies') + ->icon('heroicon-o-plus') + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { + $backupSet = $this->getOwnerRecord(); - return view('filament.modals.backup-set-policy-picker', [ - 'backupSetId' => $backupSet->getKey(), - ]); - }), + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); + }), + ), ]) ->actions([ Actions\ActionGroup::make([ @@ -164,174 +162,156 @@ 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(); + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( + Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record): void { + $backupSet = $this->getOwnerRecord(); + $tenant = $backupSet->tenant; - $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->authorizeOrAbort($tenant); - $tenant = $backupSet->tenant ?? Tenant::current(); + /** @var User $user */ + $user = auth()->user(); - if (! $tenant instanceof Tenant) { - abort(404); - } + $backupItemIds = [(int) $record->getKey()]; - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } + /** @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 ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { - abort(403); - } + 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(); - $backupItemIds = [(int) $record->getKey()]; + 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, - ); + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); - 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() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + 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 ($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(); - }), + }), + ), ])->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; - } + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( + 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(); + $backupSet = $this->getOwnerRecord(); + $tenant = $backupSet->tenant; - $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->authorizeOrAbort($tenant); - $tenant = $backupSet->tenant ?? Tenant::current(); + /** @var User $user */ + $user = auth()->user(); - if (! $tenant instanceof Tenant) { - 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 (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } + if ($backupItemIds === []) { + return; + } - if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { - abort(403); - } + /** @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, + ); - $backupItemIds = $records - ->pluck('id') - ->map(fn (mixed $value): int => (int) $value) - ->filter(fn (int $value): bool => $value > 0) - ->unique() - ->sort() - ->values() - ->all(); + 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(); - if ($backupItemIds === []) { - return; - } + 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, - ); + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); - 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() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + 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 ($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(); - }), + }), + ), ]), ]); } diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index c2bca06..3f786b6 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -11,11 +11,11 @@ use App\Services\Directory\EntraGroupSelection; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListEntraGroups extends ListRecords { @@ -30,80 +30,20 @@ protected function getHeaderActions(): array ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) ->visible(fn (): bool => (bool) Tenant::current()), - Action::make('sync_groups') + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Action::make('sync_groups') ->label('Sync Groups') ->icon('heroicon-o-arrow-path') ->color('warning') - ->visible(function (): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenant = Tenant::current(); - - if (! $tenant) { - return false; - } - - if (! $user->canAccessTenant($tenant)) { - return false; - } - - return true; - }) - ->disabled(function (): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); - - 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 { + UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); + $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - $tenant = Tenant::current(); - if (! $tenant) { - abort(403); + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; } - 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 --- @@ -182,7 +122,7 @@ protected function getHeaderActions(): array ]) ->sendToDatabase($user) ->send(); - }), + })), ]; } } diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index 012d578..f8d7a23 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -10,9 +10,9 @@ use App\Notifications\RunStatusChangedNotification; use App\Services\Directory\EntraGroupSelection; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListEntraGroupSyncRuns extends ListRecords { @@ -21,49 +21,22 @@ 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::for(Capabilities::TENANT_SYNC)->apply( + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (): void { + UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); - if (! $user instanceof User) { - return false; - } + $user = auth()->user(); + $tenant = Tenant::current(); - $tenant = Tenant::current(); + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; + } - if (! $tenant) { - return false; - } - - if (! $user->canAccessTenant($tenant)) { - return false; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->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(); + $selectionKey = EntraGroupSelection::allGroupsV1(); $existing = EntraGroupSyncRun::query() ->where('tenant_id', $tenant->getKey()) @@ -106,7 +79,8 @@ protected function getHeaderActions(): array 'run_id' => (int) $run->getKey(), 'status' => 'queued', ])); - }), + }), + ), ]; } } diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 2f07d97..b88707e 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -9,6 +9,7 @@ use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -17,6 +18,7 @@ use App\Support\Inventory\InventoryPolicyTypeMeta; use BackedEnum; use Filament\Actions; +use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -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 @@ -43,21 +44,18 @@ class InventoryItemResource extends Resource public static function canViewAny(): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed(); } public static function canView(Model $record): bool { - $tenant = Tenant::current(); + $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) { return false; } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 56daeaf..dccfe65 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -12,6 +12,7 @@ use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; @@ -24,7 +25,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,140 +40,91 @@ 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::for(Capabilities::TENANT_SYNC)->apply( + 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(), + ]) + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return false; - } + $tenant = Tenant::current(); + $user = auth()->user(); - return $user->canAccessTenant($tenant); - }) - ->disabled(function (): bool { - $user = auth()->user(); - if (! $user instanceof User) { - return true; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return true; - } + $requestedTenantId = $data['tenant_id'] ?? null; + if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { + Notification::make() + ->title('Not allowed') + ->danger() + ->send(); - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); - 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 start inventory sync.'; - }) - ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - abort(404); - } - - $user = auth()->user(); - if (! $user instanceof User) { - abort(403, 'Not allowed'); - } - - if (! $user->canAccessTenant($tenant)) { - abort(404); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403, 'Not allowed'); - } - - $requestedTenantId = $data['tenant_id'] ?? null; - if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { - Notification::make() - ->title('Not allowed') - ->danger() - ->send(); - - abort(403, 'Not allowed'); - } + throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, 'Not allowed'); + } $selectionPayload = $inventorySyncService->defaultSelectionPayload(); if (array_key_exists('policy_types', $data)) { @@ -277,7 +228,8 @@ protected function getHeaderActions(): array ->send(); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - }), + }), + ), ]; } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 44d8670..341eea0 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -7,11 +7,13 @@ use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use BackedEnum; use Filament\Actions; +use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -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 @@ -40,21 +41,18 @@ class InventorySyncRunResource extends Resource public static function canViewAny(): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed(); } public static function canView(Model $record): bool { - $tenant = Tenant::current(); + $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) { return false; } diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 6e2377f..0d3c792 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -15,6 +15,7 @@ use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -29,7 +30,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 @@ -55,17 +55,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 => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) ->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 => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) ->rules(['uuid']), Toggle::make('is_default') ->label('Default connection') - ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) + ->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) ->helperText('Exactly one default connection is required per tenant/provider.'), TextInput::make('status') ->label('Status') @@ -146,55 +146,25 @@ public static function table(Table $table): Table ]) ->actions([ Actions\ActionGroup::make([ - Actions\EditAction::make(), + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply( + Actions\EditAction::make(), + ), - Actions\Action::make('check_connection') + UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('check_connection') ->label('Check connection') ->icon('heroicon-o-check-badge') ->color('success') - ->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.'; - }) + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + $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); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + $initiator = $user; $result = $gate->start( @@ -252,55 +222,23 @@ public static function table(Table $table): Table ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); - }), + })), - Actions\Action::make('inventory_sync') + UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('inventory_sync') ->label('Inventory sync') ->icon('heroicon-o-arrow-path') ->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.'; - }) + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + $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); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + $initiator = $user; $result = $gate->start( @@ -358,55 +296,23 @@ public static function table(Table $table): Table ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); - }), + })), - Actions\Action::make('compliance_snapshot') + UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(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.'; - }) + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + $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); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + $initiator = $user; $result = $gate->start( @@ -464,19 +370,21 @@ public static function table(Table $table): Table ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); - }), + })), - Actions\Action::make('set_default') + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(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) + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant) { + return; + } $record->makeDefault(); @@ -506,14 +414,13 @@ public static function table(Table $table): Table ->title('Default connection updated') ->success() ->send(); - }), + })), - Actions\Action::make('update_credentials') + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(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') @@ -526,9 +433,13 @@ public static function table(Table $table): Table ->maxLength(255), ]) ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant) { + return; + } $credentials->upsertClientSecretCredential( connection: $record, @@ -562,18 +473,21 @@ public static function table(Table $table): Table ->title('Credentials updated') ->success() ->send(); - }), + })), - Actions\Action::make('enable_connection') + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(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') + ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant) { + return; + } $hadCredentials = $record->credential()->exists(); $status = $hadCredentials ? 'connected' : 'needs_consent'; @@ -626,19 +540,22 @@ public static function table(Table $table): Table ->title('Provider connection enabled') ->success() ->send(); - }), + })), - Actions\Action::make('disable_connection') + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(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') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + if (! $tenant instanceof Tenant) { + return; + } $previousStatus = (string) $record->status; @@ -673,7 +590,7 @@ public static function table(Table $table): Table ->title('Provider connection disabled') ->warning() ->send(); - }), + })), ]) ->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..00403c2 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -14,6 +14,7 @@ use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\OperationRunLinks; use Filament\Actions; use Filament\Actions\Action; @@ -21,7 +22,6 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; class EditProviderConnection extends EditRecord { @@ -108,89 +108,67 @@ protected function afterSave(): void protected function getHeaderActions(): array { - $tenant = Tenant::current(); - return [ Actions\DeleteAction::make() ->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 { + UiEnforcement::for(Capabilities::PROVIDER_VIEW) + ->andVisibleWhen(function (ProviderConnection $record): bool { $tenant = Tenant::current(); - 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(); - - if (! $run instanceof OperationRun) { - return null; - } - - return OperationRunLinks::view($run, $tenant); - }), - - 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'; + && OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->exists(); }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + ->apply( + Action::make('view_last_check_run') + ->label('View last check run') + ->icon('heroicon-o-eye') + ->color('gray') + ->url(function (ProviderConnection $record): ?string { + $tenant = Tenant::current(); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + if (! $tenant instanceof Tenant) { + return null; + } - return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); + $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 (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } + if (! $run instanceof OperationRun) { + 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(); + return OperationRunLinks::view($run, $tenant); + }), + ), - 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; + UiEnforcement::for(Capabilities::PROVIDER_RUN) + ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->apply( + Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $initiator = $user; $result = $gate->start( tenant: $tenant, @@ -247,29 +225,34 @@ protected function getHeaderActions(): array ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); - }), + }), + ), - 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(); + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply( + 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 { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } $credentials->upsertClientSecretCredential( connection: $record, @@ -303,24 +286,34 @@ protected function getHeaderActions(): array ->title('Credentials updated') ->success() ->send(); - }), + }), + ), - 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 { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE) + ->andVisibleWhen(function (ProviderConnection $record): bool { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + return $record->status !== 'disabled' + && ! $record->is_default + && $tenant instanceof Tenant + && ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->where('provider', $record->provider) + ->count() > 1; + }) + ->apply( + Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } $record->makeDefault(); @@ -350,52 +343,27 @@ protected function getHeaderActions(): array ->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(); + UiEnforcement::for(Capabilities::PROVIDER_RUN) + ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->apply( + Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); - return $tenant instanceof Tenant - && $user instanceof User - && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - 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; + $initiator = $user; $result = $gate->start( tenant: $tenant, @@ -452,52 +420,27 @@ protected function getHeaderActions(): array ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); - }), + }), + ), - 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(); + UiEnforcement::for(Capabilities::PROVIDER_RUN) + ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->apply( + Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); - return $tenant instanceof Tenant - && $user instanceof User - && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - 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; + $initiator = $user; $result = $gate->start( tenant: $tenant, @@ -554,19 +497,24 @@ protected function getHeaderActions(): array ->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(); + UiEnforcement::for(Capabilities::PROVIDER_MANAGE) + ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->apply( + Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } $hadCredentials = $record->credential()->exists(); $status = $hadCredentials ? 'connected' : 'needs_consent'; @@ -619,20 +567,25 @@ protected function getHeaderActions(): array ->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(); + UiEnforcement::for(Capabilities::PROVIDER_MANAGE) + ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->apply( + Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } $previousStatus = (string) $record->status; @@ -667,7 +620,8 @@ protected function getHeaderActions(): array ->title('Provider connection disabled') ->warning() ->send(); - }), + }), + ), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') @@ -677,9 +631,7 @@ protected function getHeaderActions(): array protected function getFormActions(): array { - $tenant = Tenant::current(); - - if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) { + if (UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) { return parent::getFormActions(); } @@ -690,9 +642,7 @@ protected function getFormActions(): array protected function handleRecordUpdate(Model $record, array $data): Model { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); + UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); 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..0e2cbda 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\Auth\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -13,11 +15,9 @@ 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::for(Capabilities::PROVIDER_MANAGE)->apply( + Actions\CreateAction::make(), + ), ]; } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 45f256e..ddd7419 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -22,6 +22,7 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -50,7 +51,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 +65,7 @@ class RestoreRunResource extends Resource public static function canCreate(): bool { - return ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant); + return UiEnforcement::for(Capabilities::TENANT_MANAGE)->isAllowed(); } public static function form(Schema $schema): Schema @@ -748,7 +747,7 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ - Actions\Action::make('rerun') + UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('rerun') ->label('Rerun') ->icon('heroicon-o-arrow-path') ->color('primary') @@ -761,17 +760,13 @@ public static function table(Table $table): Table && $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(); - - abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403); + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); $tenant = $record->tenant; $backupSet = $record->backupSet; @@ -932,19 +927,15 @@ public static function table(Table $table): Table OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') ->send(); - }), - Actions\Action::make('restore') + })), + UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); $record->restore(); @@ -963,19 +954,15 @@ public static function table(Table $table): Table ->title('Restore run restored') ->success() ->send(); - }), - Actions\Action::make('archive') + })), + UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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); + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); if (! $record->isDeletable()) { Notification::make() @@ -1004,19 +991,15 @@ public static function table(Table $table): Table ->title('Restore run archived') ->success() ->send(); - }), - Actions\Action::make('forceDelete') + })), + UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(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); + UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); if ($record->tenant) { $auditLogger->log( @@ -1035,18 +1018,16 @@ public static function table(Table $table): Table ->title('Restore run permanently deleted') ->success() ->send(); - }), + })), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('bulk_delete') + UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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; @@ -1071,17 +1052,13 @@ public static function table(Table $table): Table return []; }) ->action(function (Collection $records) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + $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 */ @@ -1121,15 +1098,13 @@ public static function table(Table $table): Table ]) ->send(); }) - ->deselectRecordsAfterCompletion(), + ->deselectRecordsAfterCompletion()), - BulkAction::make('bulk_restore') + UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(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; @@ -1141,17 +1116,13 @@ public static function table(Table $table): Table ->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) { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + $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 */ @@ -1202,15 +1173,13 @@ public static function table(Table $table): Table ]) ->send(); }) - ->deselectRecordsAfterCompletion(), + ->deselectRecordsAfterCompletion()), - BulkAction::make('bulk_force_delete') + UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(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; @@ -1231,17 +1200,13 @@ public static function table(Table $table): Table ]), ]) ->action(function (Collection $records) { + UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); + $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_DELETE, $tenant), 403); - $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -1292,7 +1257,7 @@ public static function table(Table $table): Table ]) ->send(); }) - ->deselectRecordsAfterCompletion(), + ->deselectRecordsAfterCompletion()), ]), ]); } @@ -1491,17 +1456,15 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array public static function createRestoreRun(array $data): RestoreRun { + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + /** @var Tenant $tenant */ $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - /** @var BackupSet $backupSet */ - $backupSet = BackupSet::findOrFail($data['backup_set_id']); - - if ($backupSet->tenant_id !== $tenant->id) { - abort(403, 'Backup set does not belong to the active tenant.'); - } + $backupSet = BackupSet::query() + ->where('tenant_id', $tenant->getKey()) + ->findOrFail($data['backup_set_id']); /** @var RestoreService $service */ $service = app(RestoreService::class); diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 19986f2..1ef6b34 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -6,11 +6,11 @@ use App\Models\BackupSet; use App\Models\Tenant; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; 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 @@ -21,9 +21,7 @@ class CreateRestoreRun extends CreateRecord protected function authorizeAccess(): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); } public function getSteps(): array diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php index 1868ac5..fb11d2e 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php @@ -3,6 +3,8 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -13,9 +15,7 @@ class ListRestoreRuns extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() - ->disabled(fn (): bool => ! RestoreRunResource::canCreate()) - ->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'), + UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()), ]; } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index b03a866..641c14b 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -20,6 +20,7 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -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; @@ -73,24 +73,16 @@ public static function canCreate(): bool public static function canEdit(Model $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + return UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->tenantFromRecord() + ->isAllowed($record); } public static function canDelete(Model $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + return UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->isAllowed($record); } public static function canDeleteAny(): bool @@ -106,36 +98,30 @@ public static function canDeleteAny(): bool private static function userCanManageAnyTenant(User $user): bool { - $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); + $roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_MANAGE); - if ($tenantIds->isEmpty()) { + if ($roles === []) { 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->tenants() + ->withTrashed() + ->wherePivotIn('role', $roles) + ->exists(); } private static function userCanDeleteAnyTenant(User $user): bool { - $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); + $roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_DELETE); - if ($tenantIds->isEmpty()) { + if ($roles === []) { 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->tenants() + ->withTrashed() + ->wherePivotIn('role', $roles) + ->exists(); } public static function form(Schema $schema): Schema @@ -274,49 +260,19 @@ public static function table(Table $table): Table ->label('View') ->icon('heroicon-o-eye') ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)), - Actions\Action::make('syncTenant') + UiEnforcement::for(Capabilities::TENANT_SYNC)->tenantFromRecord()->apply(Actions\Action::make('syncTenant') ->label('Sync') ->icon('heroicon-o-arrow-path') ->color('warning') ->requiresConfirmation() - ->visible(function (Tenant $record): bool { - if (! $record->isActive()) { - return false; - } - - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - return $user->canAccessTenant($record); - }) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record); - }) - ->tooltip(function (Tenant $record): ?string { - $user = auth()->user(); - - if (! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record) - ? null - : 'You do not have permission to sync this tenant.'; - }) + ->visible(fn (Tenant $record): bool => $record->isActive()) ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->authorizeOrAbort($record); + + /** @var User $user */ $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); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -337,7 +293,7 @@ public static function table(Table $table): Table tenant: $record, type: 'policy.sync', inputs: $inputs, - initiator: auth()->user() + initiator: $user ); if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) { @@ -350,7 +306,7 @@ public static function table(Table $table): Table tenant: $record, type: 'policy.sync', inputs: $inputs, - initiator: auth()->user() + initiator: $user ); } @@ -390,44 +346,29 @@ public static function table(Table $table): Table ->url(OperationRunLinks::view($opRun, $record)), ]) ->send(); - }), + })), Actions\Action::make('openTenant') ->label('Open') ->icon('heroicon-o-arrow-right') ->color('primary') ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) ->visible(fn (Tenant $record) => $record->isActive()), - Actions\Action::make('edit') - ->label('Edit') - ->icon('heroicon-o-pencil-square') - ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)) - ->disabled(fn (Tenant $record): bool => ! static::canEdit($record)) - ->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'), - Actions\Action::make('restore') + UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply( + Actions\Action::make('edit') + ->label('Edit') + ->icon('heroicon-o-pencil-square') + ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)), + ), + UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('restore') ->label('Restore') ->color('success') ->successNotificationTitle('Tenant reactivated') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => $record->trashed()) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); - }) ->action(function (Tenant $record, AuditLogger $auditLogger): void { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->authorizeOrAbort($record); $record->restore(); @@ -439,54 +380,25 @@ public static function table(Table $table): Table status: 'success', context: ['metadata' => ['tenant_id' => $record->tenant_id]] ); - }), - Actions\Action::make('admin_consent') + })), + UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('admin_consent') ->label('Admin consent') ->icon('heroicon-o-clipboard-document') ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); - }) - ->tooltip(function (Tenant $record): ?string { - $user = auth()->user(); - - if (! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record) - ? null - : 'You do not have permission to manage tenant consent.'; - }) - ->openUrlInNewTab(), + ->openUrlInNewTab()), Actions\Action::make('open_in_entra') ->label('Open in Entra') ->icon('heroicon-o-arrow-top-right-on-square') ->url(fn (Tenant $record) => static::entraUrl($record)) ->visible(fn (Tenant $record) => static::entraUrl($record) !== null) ->openUrlInNewTab(), - Actions\Action::make('verify') + UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') ->color('primary') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => $record->isActive()) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); - }) ->action(function ( Tenant $record, TenantConfigService $configService, @@ -494,44 +406,23 @@ public static function table(Table $table): Table RbacHealthService $rbacHealthService, AuditLogger $auditLogger ) { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->tenantFromRecord() + ->authorizeOrAbort($record); static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); - }), + })), static::rbacAction(), - Actions\Action::make('archive') + UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('archive') ->label('Deactivate') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => ! $record->trashed()) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); - }) ->action(function (Tenant $record, AuditLogger $auditLogger) { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->authorizeOrAbort($record); $record->delete(); @@ -549,40 +440,21 @@ public static function table(Table $table): Table ->body('The tenant has been archived and hidden from lists.') ->success() ->send(); - }), - Actions\Action::make('forceDelete') + })), + UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (?Tenant $record): bool => (bool) $record?->trashed()) - ->disabled(function (?Tenant $record): bool { - if (! $record instanceof Tenant) { - return true; - } - - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); - }) ->action(function (?Tenant $record, AuditLogger $auditLogger) { if ($record === null) { return; } - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->authorizeOrAbort($record); $tenant = Tenant::withTrashed()->find($record->id); @@ -610,107 +482,100 @@ public static function table(Table $table): Table ->title('Tenant permanently deleted') ->success() ->send(); - }), + })), ]), ]) ->bulkActions([ - Actions\BulkAction::make('syncSelected') - ->label('Sync selected') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->requiresConfirmation() - ->visible(function (): bool { - $user = auth()->user(); + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->preflightByCapability() + ->apply(Actions\BulkAction::make('syncSelected') + ->label('Sync selected') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->action(function (Collection $records, AuditLogger $auditLogger): void { + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->authorizeBulkSelectionOrAbort($records); - if (! $user instanceof User) { - return false; - } + /** @var User $user */ + $user = auth()->user(); - return $user->tenants() - ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) - ->exists(); - }) - ->authorize(function (): bool { - $user = auth()->user(); + $eligible = $records + ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()); - if (! $user instanceof User) { - return false; - } + if ($eligible->isEmpty()) { + Notification::make() + ->title('Bulk sync skipped') + ->body('No eligible tenants selected.') + ->icon('heroicon-o-information-circle') + ->info() + ->sendToDatabase($user) + ->send(); - return $user->tenants() - ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) - ->exists(); - }) - ->action(function (Collection $records, AuditLogger $auditLogger): void { - $user = auth()->user(); + return; + } - if (! $user instanceof User) { - return; - } + if ($eligible->count() !== $records->count()) { + $skipped = $records->count() - $eligible->count(); + $total = $records->count(); - $eligible = $records - ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) - ->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); + Notification::make() + ->title('Some tenants were skipped') + ->body("Skipped {$skipped} of {$total} selected tenants (inactive).") + ->warning() + ->sendToDatabase($user) + ->send(); + } - if ($eligible->isEmpty()) { - Notification::make() - ->title('Bulk sync skipped') - ->body('No eligible tenants selected.') - ->icon('heroicon-o-information-circle') - ->info() - ->sendToDatabase($user) + $tenantContext = Tenant::current() ?? $eligible->first(); + + if (! $tenantContext) { + return; + } + + $ids = $eligible->pluck('id')->toArray(); + $count = $eligible->count(); + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenantContext, + type: 'tenant.sync', + targetScope: [ + 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void { + BulkTenantSyncJob::dispatch( + tenantId: (int) $tenantContext->getKey(), + userId: (int) $user->getKey(), + tenantIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'tenant_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('tenant.sync') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenantContext)), + ]) ->send(); - - return; - } - - $tenantContext = Tenant::current() ?? $eligible->first(); - - if (! $tenantContext) { - return; - } - - $ids = $eligible->pluck('id')->toArray(); - $count = $eligible->count(); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenantContext, - type: 'tenant.sync', - targetScope: [ - 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void { - BulkTenantSyncJob::dispatch( - tenantId: (int) $tenantContext->getKey(), - userId: (int) $user->getKey(), - tenantIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'tenant_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('tenant.sync') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenantContext)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + }) + ->deselectRecordsAfterCompletion()), ]) ->headerActions([]); } @@ -803,7 +668,7 @@ public static function getRelations(): array public static function rbacAction(): Actions\Action { // ... [RBAC Action Omitted - No Change] ... - return Actions\Action::make('setup_rbac') + return UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('setup_rbac') ->label('Setup Intune RBAC') ->icon('heroicon-o-shield-check') ->color('primary') @@ -886,15 +751,6 @@ public static function rbacAction(): Actions\Action ->loadingMessage('Searching groups...'), ]) ->visible(fn (Tenant $record): bool => $record->isActive()) - ->disabled(function (Tenant $record): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); - }) ->requiresConfirmation() ->action(function ( array $data, @@ -902,15 +758,9 @@ public static function rbacAction(): Actions\Action RbacOnboardingService $service, AuditLogger $auditLogger ) { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { - abort(403); - } + UiEnforcement::for(Capabilities::TENANT_MANAGE) + ->tenantFromRecord() + ->authorizeOrAbort($record); $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); $token = Cache::get($cacheKey); @@ -989,7 +839,7 @@ public static function rbacAction(): Actions\Action ->body($result['message'] ?? 'Unknown error') ->danger() ->send(); - }); + })); } public static function adminConsentUrl(Tenant $tenant): ?string diff --git a/app/Filament/Resources/TenantResource/Pages/EditTenant.php b/app/Filament/Resources/TenantResource/Pages/EditTenant.php index 19117cf..173a4c0 100644 --- a/app/Filament/Resources/TenantResource/Pages/EditTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/EditTenant.php @@ -4,11 +4,10 @@ use App\Filament\Resources\TenantResource; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\Capabilities; +use App\Support\Auth\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\EditRecord; -use Illuminate\Support\Facades\Gate; class EditTenant extends EditRecord { @@ -18,42 +17,24 @@ 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(); + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->apply( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed()) + ->action(function (): void { + $tenant = $this->record; - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } + UiEnforcement::for(Capabilities::TENANT_DELETE) + ->tenantFromRecord() + ->authorizeOrAbort($tenant); - 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(); - }), + $tenant->delete(); + }), + ), ]; } } diff --git a/app/Support/Auth/UiEnforcement.php b/app/Support/Auth/UiEnforcement.php new file mode 100644 index 0000000..9686556 --- /dev/null +++ b/app/Support/Auth/UiEnforcement.php @@ -0,0 +1,526 @@ +): bool|null + */ + private ?\Closure $bulkPreflight = null; + + public function __construct(private string $capability) + { + } + + public static function for(string $capability): self + { + return new self($capability); + } + + public function preserveVisibility(): self + { + if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { + throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); + } + + $this->preserveVisibility = true; + + return $this; + } + + public function andVisibleWhen(callable $businessVisible): self + { + $this->businessVisible = \Closure::fromCallable($businessVisible); + + return $this; + } + + public function andHiddenWhen(callable $businessHidden): self + { + $this->businessHidden = \Closure::fromCallable($businessHidden); + + return $this; + } + + public function tenantFromFilament(): self + { + $this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT; + $this->customTenantResolver = null; + + return $this; + } + + public function tenantFromRecord(): self + { + if ($this->preserveVisibility) { + throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); + } + + $this->tenantResolverMode = self::TENANT_RESOLVER_RECORD; + $this->customTenantResolver = null; + + return $this; + } + + public function tenantFrom(callable $resolver): self + { + if ($this->preserveVisibility) { + throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); + } + + $this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM; + $this->customTenantResolver = \Closure::fromCallable($resolver); + + return $this; + } + + /** + * Custom bulk authorization preflight for selection. + * + * Signature: fn (Collection $records): bool + */ + public function preflightSelection(callable $preflight): self + { + $this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM; + $this->bulkPreflight = \Closure::fromCallable($preflight); + + return $this; + } + + public function preflightByTenantMembership(): self + { + $this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP; + $this->bulkPreflight = null; + + return $this; + } + + public function preflightByCapability(): self + { + $this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY; + $this->bulkPreflight = null; + + return $this; + } + + public function apply(Action $action): Action + { + $this->assertMixedVisibilityConfigIsValid(); + + if (! $this->preserveVisibility) { + $this->applyVisibility($action); + } + + if ($action->isBulk()) { + $action->disabled(function () use ($action): bool { + /** @var Collection $records */ + $records = collect($action->getSelectedRecords()); + + return $this->bulkIsDisabled($records); + }); + + $action->tooltip(function () use ($action): ?string { + /** @var Collection $records */ + $records = collect($action->getSelectedRecords()); + + return $this->bulkDisabledTooltip($records); + }); + } else { + $action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record)); + $action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record)); + } + + return $action; + } + + public function isAllowed(?Model $record = null): bool + { + return ! $this->isDisabled($record); + } + + public function authorizeOrAbort(?Model $record = null): void + { + $user = auth()->user(); + abort_unless($user instanceof User, 403); + + $tenant = $this->resolveTenant($record); + + if (! ($tenant instanceof Tenant)) { + abort(404); + } + + abort_unless($this->isMemberOfTenant($user, $tenant), 404); + abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 403); + } + + /** + * Server-side enforcement for bulk selections. + * + * - If any selected tenant is not a membership: 404 (deny-as-not-found). + * - If all are memberships but any lacks capability: 403. + * + * @param Collection $records + */ + public function authorizeBulkSelectionOrAbort(Collection $records): void + { + $user = auth()->user(); + abort_unless($user instanceof User, 403); + + $tenantIds = $this->resolveTenantIdsForRecords($records); + + if ($tenantIds === []) { + abort(403); + } + + $membershipTenantIds = $this->membershipTenantIds($user, $tenantIds); + + if (count($membershipTenantIds) !== count($tenantIds)) { + abort(404); + } + + $allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds); + + if (count($allowedTenantIds) !== count($tenantIds)) { + abort(403); + } + } + + /** + * Public helper for evaluating bulk selection authorization decisions. + * + * @param Collection $records + */ + public function bulkSelectionIsAuthorized(User $user, Collection $records): bool + { + return $this->bulkSelectionIsAuthorizedInternal($user, $records); + } + + private function applyVisibility(Action $action): void + { + $canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT); + + $businessVisible = $this->businessVisible; + $businessHidden = $this->businessHidden; + + if ($businessVisible instanceof \Closure) { + $action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool { + if (! (bool) $action->evaluate($businessVisible)) { + return false; + } + + if (! $canApplyMemberVisibility) { + return true; + } + + $record = $action->getRecord(); + + return $this->isMember($record instanceof Model ? $record : null); + }); + } + + if ($businessHidden instanceof \Closure) { + $action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool { + if ($canApplyMemberVisibility) { + $record = $action->getRecord(); + + if (! $this->isMember($record instanceof Model ? $record : null)) { + return true; + } + } + + return (bool) $action->evaluate($businessHidden); + }); + + return; + } + + if (! $canApplyMemberVisibility) { + return; + } + + if (! ($businessVisible instanceof \Closure)) { + $action->hidden(function () use ($action): bool { + $record = $action->getRecord(); + + return ! $this->isMember($record instanceof Model ? $record : null); + }); + } + } + + private function assertMixedVisibilityConfigIsValid(): void + { + if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) { + throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().'); + } + + if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { + throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); + } + } + + private function isDisabled(?Model $record = null): bool + { + $user = auth()->user(); + + if (! ($user instanceof User)) { + return true; + } + + $tenant = $this->resolveTenant($record); + + if (! ($tenant instanceof Tenant)) { + return true; + } + + if (! $this->isMemberOfTenant($user, $tenant)) { + return true; + } + + return ! Gate::forUser($user)->allows($this->capability, $tenant); + } + + private function disabledTooltip(?Model $record = null): ?string + { + $user = auth()->user(); + + if (! ($user instanceof User)) { + return null; + } + + $tenant = $this->resolveTenant($record); + + if (! ($tenant instanceof Tenant)) { + return null; + } + + if (! $this->isMemberOfTenant($user, $tenant)) { + return null; + } + + if (Gate::forUser($user)->allows($this->capability, $tenant)) { + return null; + } + + return UiTooltips::insufficientPermission(); + } + + private function bulkIsDisabled(Collection $records): bool + { + $user = auth()->user(); + + if (! ($user instanceof User)) { + return true; + } + + return ! $this->bulkSelectionIsAuthorizedInternal($user, $records); + } + + private function bulkDisabledTooltip(Collection $records): ?string + { + $user = auth()->user(); + + if (! ($user instanceof User)) { + return null; + } + + if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) { + return null; + } + + return UiTooltips::insufficientPermission(); + } + + private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool + { + if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) { + return (bool) ($this->bulkPreflight)($records); + } + + $tenantIds = $this->resolveTenantIdsForRecords($records); + + if ($tenantIds === []) { + return false; + } + + return match ($this->bulkPreflightMode) { + self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds), + self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds), + default => false, + }; + } + + /** + * @param Collection $records + * @return array + */ + private function resolveTenantIdsForRecords(Collection $records): array + { + if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) { + $tenant = Filament::getTenant(); + + return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : []; + } + + if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) { + $ids = $records + ->filter(fn (Model $record): bool => $record instanceof Tenant) + ->map(fn (Tenant $tenant): int => (int) $tenant->getKey()) + ->all(); + + return array_values(array_unique($ids)); + } + + if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) { + $ids = []; + + foreach ($records as $record) { + if (! ($record instanceof Model)) { + continue; + } + + $resolved = ($this->customTenantResolver)($record); + + if ($resolved instanceof Tenant) { + $ids[] = (int) $resolved->getKey(); + continue; + } + + if (is_int($resolved)) { + $ids[] = $resolved; + } + } + + return array_values(array_unique($ids)); + } + + return []; + } + + private function isMember(?Model $record = null): bool + { + $user = auth()->user(); + + if (! ($user instanceof User)) { + return false; + } + + $tenant = $this->resolveTenant($record); + + if (! ($tenant instanceof Tenant)) { + return false; + } + + return $this->isMemberOfTenant($user, $tenant); + } + + private function isMemberOfTenant(User $user, Tenant $tenant): bool + { + return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant); + } + + private function resolveTenant(?Model $record = null): ?Tenant + { + return match ($this->tenantResolverMode) { + self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null, + self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null, + self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record), + default => null, + }; + } + + private function resolveTenantViaCustomResolver(?Model $record): ?Tenant + { + if (! ($this->customTenantResolver instanceof \Closure)) { + return null; + } + + if (! ($record instanceof Model)) { + return null; + } + + $resolved = ($this->customTenantResolver)($record); + + if ($resolved instanceof Tenant) { + return $resolved; + } + + return null; + } + + /** + * @param array $tenantIds + * @return array + */ + private function membershipTenantIds(User $user, array $tenantIds): array + { + /** @var array $ids */ + $ids = DB::table('tenant_memberships') + ->where('user_id', (int) $user->getKey()) + ->whereIn('tenant_id', $tenantIds) + ->pluck('tenant_id') + ->map(fn ($id): int => (int) $id) + ->all(); + + return array_values(array_unique($ids)); + } + + /** + * @param array $tenantIds + * @return array + */ + private function capabilityTenantIds(User $user, array $tenantIds): array + { + $roles = RoleCapabilityMap::rolesWithCapability($this->capability); + + if ($roles === []) { + return []; + } + + /** @var array $ids */ + $ids = DB::table('tenant_memberships') + ->where('user_id', (int) $user->getKey()) + ->whereIn('tenant_id', $tenantIds) + ->whereIn('role', $roles) + ->pluck('tenant_id') + ->map(fn ($id): int => (int) $id) + ->all(); + + return array_values(array_unique($ids)); + } +} diff --git a/app/Support/Auth/UiTooltips.php b/app/Support/Auth/UiTooltips.php new file mode 100644 index 0000000..05dd60a --- /dev/null +++ b/app/Support/Auth/UiTooltips.php @@ -0,0 +1,14 @@ +create(); $tenant->makeCurrent(); + Filament::setTenant($tenant, true); [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/tests/Feature/Filament/BackupSetUiEnforcementTest.php b/tests/Feature/Filament/BackupSetUiEnforcementTest.php new file mode 100644 index 0000000..5dcb540 --- /dev/null +++ b/tests/Feature/Filament/BackupSetUiEnforcementTest.php @@ -0,0 +1,71 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($user) + ->get(BackupSetResource::getUrl('index', tenant: $tenant)) + ->assertStatus(404); +}); + +test('members without capability see BackupSet actions disabled with standard tooltip and cannot execute', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListBackupSets::class) + ->assertTableActionDisabled('archive', $backupSet) + ->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $backupSet) + ->callTableAction('archive', $backupSet); + + expect($backupSet->fresh()->trashed())->toBeFalse(); +}); + +test('members with capability can execute BackupSet actions', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListBackupSets::class) + ->assertTableActionEnabled('archive', $backupSet) + ->callTableAction('archive', $backupSet); + + expect($backupSet->fresh()->trashed())->toBeTrue(); +}); + diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php index e696853..b0737c2 100644 --- a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -7,13 +7,19 @@ use App\Models\Tenant; use App\Models\User; use App\Notifications\RunStatusChangedNotification; +use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; uses(RefreshDatabase::class); +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + test('entra group sync runs are listed for the active tenant', function () { $tenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create(); @@ -96,3 +102,22 @@ expect($notification->data['actions'][0]['url'] ?? null) ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); }); + +test('sync groups action is disabled for readonly users with standard tooltip', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListEntraGroupSyncRuns::class) + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups') + ->assertActionExists('sync_groups', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Filament/InventoryItemResourceTest.php b/tests/Feature/Filament/InventoryItemResourceTest.php index feae540..f46e5bd 100644 --- a/tests/Feature/Filament/InventoryItemResourceTest.php +++ b/tests/Feature/Filament/InventoryItemResourceTest.php @@ -1,12 +1,21 @@ create(); $otherTenant = Tenant::factory()->create(); @@ -39,3 +48,28 @@ ->assertSee('Item A') ->assertDontSee('Item B'); }); + +test('non-members are denied access to inventory item tenant routes (404)', function () { + $tenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($user) + ->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertStatus(404); +}); + +test('members without capability see inventory sync action disabled with standard tooltip', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListInventoryItems::class) + ->assertActionVisible('run_inventory_sync') + ->assertActionDisabled('run_inventory_sync') + ->assertActionExists('run_inventory_sync', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); +}); diff --git a/tests/Feature/Filament/InventorySyncRunResourceTest.php b/tests/Feature/Filament/InventorySyncRunResourceTest.php index 654eb3a..b17de43 100644 --- a/tests/Feature/Filament/InventorySyncRunResourceTest.php +++ b/tests/Feature/Filament/InventorySyncRunResourceTest.php @@ -4,9 +4,14 @@ use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; +use Illuminate\Support\Facades\Http; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + test('inventory sync runs are listed for the active tenant', function () { $tenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create(); @@ -35,3 +40,14 @@ ->assertSee(str_repeat('a', 12)) ->assertDontSee(str_repeat('b', 12)); }); + +test('non-members are denied access to inventory sync run tenant routes (404)', function () { + $tenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($user) + ->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) + ->assertStatus(404); +}); diff --git a/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php b/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php new file mode 100644 index 0000000..8b8fce9 --- /dev/null +++ b/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php @@ -0,0 +1,77 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('index', tenant: $tenant)) + ->assertStatus(404); +}); + +test('members without capability see provider connection actions disabled with standard tooltip', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'needs_consent', + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListProviderConnections::class) + ->assertTableActionVisible('check_connection', $connection) + ->assertTableActionDisabled('check_connection', $connection) + ->assertTableActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $connection); + + Livewire::actingAs($user) + ->test(EditProviderConnection::class, ['record' => $connection->getKey()]) + ->assertActionVisible('check_connection') + ->assertActionDisabled('check_connection') + ->assertActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); +}); + +test('members with capability can see provider connection actions enabled', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'needs_consent', + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListProviderConnections::class) + ->assertTableActionVisible('check_connection', $connection) + ->assertTableActionEnabled('check_connection', $connection); + + Livewire::actingAs($user) + ->test(EditProviderConnection::class, ['record' => $connection->getKey()]) + ->assertActionVisible('check_connection') + ->assertActionEnabled('check_connection'); +}); diff --git a/tests/Feature/Filament/RestoreRunUiEnforcementTest.php b/tests/Feature/Filament/RestoreRunUiEnforcementTest.php new file mode 100644 index 0000000..f438596 --- /dev/null +++ b/tests/Feature/Filament/RestoreRunUiEnforcementTest.php @@ -0,0 +1,83 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($user) + ->get(RestoreRunResource::getUrl('index', tenant: $tenant)) + ->assertStatus(404); +}); + +test('members without capability see RestoreRun actions disabled with standard tooltip and cannot execute', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'completed', + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->assertTableActionDisabled('archive', $restoreRun) + ->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $restoreRun) + ->callTableAction('archive', $restoreRun); + + expect($restoreRun->fresh()->trashed())->toBeFalse(); +}); + +test('members with capability can execute RestoreRun actions', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'completed', + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->assertTableActionEnabled('archive', $restoreRun) + ->callTableAction('archive', $restoreRun); + + expect($restoreRun->fresh()->trashed())->toBeTrue(); +}); + diff --git a/tests/Feature/Filament/TenantActionsAuthorizationTest.php b/tests/Feature/Filament/TenantActionsAuthorizationTest.php index fad1880..e3233ec 100644 --- a/tests/Feature/Filament/TenantActionsAuthorizationTest.php +++ b/tests/Feature/Filament/TenantActionsAuthorizationTest.php @@ -4,13 +4,19 @@ use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Models\Tenant; +use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Gate; use Livewire\Livewire; uses(RefreshDatabase::class); +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + test('readonly users may switch current tenant via ChooseTenant', function () { [$user, $tenantA] = createUserWithTenant(role: 'readonly'); @@ -57,6 +63,7 @@ Livewire::actingAs($user) ->test(ListTenants::class) ->assertTableActionDisabled('archive', $tenant) + ->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant) ->callTableAction('archive', $tenant); expect($tenant->fresh()->trashed())->toBeFalse(); @@ -74,6 +81,7 @@ Livewire::actingAs($user) ->test(ListTenants::class) ->assertTableActionDisabled('forceDelete', $tenant) + ->assertTableActionExists('forceDelete', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant) ->callTableAction('forceDelete', $tenant); expect(Tenant::withTrashed()->find($tenant->getKey()))->not->toBeNull(); @@ -89,6 +97,7 @@ Livewire::actingAs($user) ->test(ListTenants::class) ->assertTableActionDisabled('verify', $tenant) + ->assertTableActionExists('verify', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant) ->callTableAction('verify', $tenant); }); @@ -113,7 +122,8 @@ Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionDisabled('edit', $tenant); + ->assertTableActionDisabled('edit', $tenant) + ->assertTableActionExists('edit', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant); }); test('readonly users cannot open admin consent', function () { @@ -126,7 +136,8 @@ Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionDisabled('admin_consent', $tenant); + ->assertTableActionDisabled('admin_consent', $tenant) + ->assertTableActionExists('admin_consent', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant); }); test('readonly users cannot start tenant sync from tenant menu', function () { @@ -138,5 +149,6 @@ Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionDisabled('syncTenant', $tenant); + ->assertTableActionDisabled('syncTenant', $tenant) + ->assertTableActionExists('syncTenant', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant); }); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index 14347cf..cde067b 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -4,14 +4,20 @@ use App\Jobs\BulkTenantSyncJob; use App\Models\Tenant; use App\Models\User; +use App\Support\Auth\UiTooltips; use Filament\Events\TenantSet; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Http; use Livewire\Livewire; uses(RefreshDatabase::class); +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + test('tenant-scoped pages return 404 for unauthorized tenant', function () { [$user, $authorizedTenant] = createUserWithTenant(); $unauthorizedTenant = Tenant::factory()->create(); @@ -21,6 +27,40 @@ ->assertNotFound(); }); +test('tenant portfolio tenant view returns 404 for non-member tenant record', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-view']); + $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-view']); + + $user->tenants()->syncWithoutDetaching([ + $authorizedTenant->getKey() => ['role' => 'owner'], + ]); + + $this->get(route('filament.admin.resources.tenants.view', array_merge( + filamentTenantRouteParams($unauthorizedTenant), + ['record' => $unauthorizedTenant], + )))->assertNotFound(); +}); + +test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $authorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-authorized-edit']); + $unauthorizedTenant = Tenant::factory()->create(['tenant_id' => 'tenant-portfolio-unauthorized-edit']); + + $user->tenants()->syncWithoutDetaching([ + $authorizedTenant->getKey() => ['role' => 'owner'], + ]); + + $this->get(route('filament.admin.resources.tenants.edit', array_merge( + filamentTenantRouteParams($unauthorizedTenant), + ['record' => $unauthorizedTenant], + )))->assertNotFound(); +}); + test('tenant portfolio lists only tenants the user can access', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -75,7 +115,9 @@ ]); }); -test('tenant portfolio bulk sync is hidden for readonly users', function () { +test('tenant portfolio bulk sync is disabled for readonly users', function () { + Bus::fake(); + $user = User::factory()->create(); $this->actingAs($user); @@ -87,8 +129,48 @@ Filament::setTenant($tenant, true); - Livewire::test(ListTenants::class) - ->assertTableBulkActionHidden('syncSelected'); + $livewire = Livewire::actingAs($user) + ->test(ListTenants::class) + ->selectTableRecords([$tenant]) + ->assertTableBulkActionVisible('syncSelected') + ->assertTableBulkActionDisabled('syncSelected'); + + $actions = $livewire->parseNestedTableBulkActions('syncSelected'); + $livewire->assertActionExists($actions, fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + + $livewire->callTableBulkAction('syncSelected', collect([$tenant])); + + Bus::assertNotDispatched(BulkTenantSyncJob::class); +}); + +test('tenant portfolio bulk sync is disabled when selection includes unauthorized tenants', function () { + Bus::fake(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-a']); + $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-mixed-b']); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'readonly'], + ]); + + Filament::setTenant($tenantA, true); + + $livewire = Livewire::actingAs($user) + ->test(ListTenants::class) + ->selectTableRecords([$tenantA, $tenantB]) + ->assertTableBulkActionVisible('syncSelected') + ->assertTableBulkActionDisabled('syncSelected'); + + $actions = $livewire->parseNestedTableBulkActions('syncSelected'); + $livewire->assertActionExists($actions, fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + + $livewire->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); + + Bus::assertNotDispatched(BulkTenantSyncJob::class); }); test('tenant set event updates user tenant preference last used timestamp', function () { diff --git a/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php new file mode 100644 index 0000000..4182760 --- /dev/null +++ b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php @@ -0,0 +1,118 @@ + $files */ + $files = collect($directories) + ->filter(fn (string $dir): bool => is_dir($dir)) + ->flatMap(function (string $dir): array { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $paths = []; + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $path = $file->getPathname(); + + if (! str_ends_with($path, '.php')) { + continue; + } + + $paths[] = $path; + } + + return $paths; + }) + ->filter(function (string $path) use ($excludedPaths, $self): bool { + if ($self && realpath($path) === $self) { + return false; + } + + foreach ($excludedPaths as $excluded) { + if (str_starts_with($path, $excluded)) { + return false; + } + } + + return true; + }) + ->values(); + + $hits = []; + + foreach ($files as $path) { + $relative = str_replace($root.'/', '', $path); + + if (in_array($relative, $allowlist, true)) { + continue; + } + + $contents = file_get_contents($path); + + if (! is_string($contents) || $contents === '') { + continue; + } + + foreach ($forbiddenPatterns as $pattern) { + if (! preg_match($pattern, $contents)) { + continue; + } + + $lines = preg_split('/\R/', $contents) ?: []; + + foreach ($lines as $index => $line) { + if (preg_match($pattern, $line)) { + $hits[] = $relative.':'.($index + 1).' -> '.trim($line); + } + } + } + } + + expect($hits)->toBeEmpty( + "Ad-hoc Filament auth patterns found (remove allowlist entries as you migrate):\n".implode("\n", $hits) + ); +}); diff --git a/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php new file mode 100644 index 0000000..5f98ccd --- /dev/null +++ b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php @@ -0,0 +1,36 @@ +count(25)->create(); + [$user] = createUserWithTenant($tenants->first(), role: 'owner'); + + foreach ($tenants->slice(1) as $tenant) { + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + } + + $enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->preflightByCapability(); + + $membershipQueries = 0; + + DB::listen(function ($query) use (&$membershipQueries): void { + if (str_contains($query->sql, 'tenant_memberships')) { + $membershipQueries++; + } + }); + + expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue(); + expect($membershipQueries)->toBe(1); +}); + diff --git a/tests/Unit/Auth/UiEnforcementTest.php b/tests/Unit/Auth/UiEnforcementTest.php new file mode 100644 index 0000000..74b4284 --- /dev/null +++ b/tests/Unit/Auth/UiEnforcementTest.php @@ -0,0 +1,128 @@ + UiEnforcement::for(Capabilities::TENANT_VIEW)->tenantFromRecord()->preserveVisibility()) + ->toThrow(LogicException::class); + + expect(fn () => UiEnforcement::for(Capabilities::TENANT_VIEW)->preserveVisibility()->tenantFromRecord()) + ->toThrow(LogicException::class); +}); + +it('hides actions for non-members on record-scoped surfaces', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant(); + + $action = Action::make('test'); + + UiEnforcement::for(Capabilities::TENANT_VIEW) + ->tenantFromRecord() + ->apply($action); + + $this->actingAs($user); + $action->record($tenant); + + expect($action->isHidden())->toBeTrue(); +}); + +it('disables actions with the standard tooltip for members without the capability', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $action = Action::make('test'); + + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->apply($action); + + $this->actingAs($user); + $action->record($tenant); + + expect($action->isHidden())->toBeFalse(); + expect($action->isDisabled())->toBeTrue(); + expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission()); +}); + +it('enables actions for members with the capability', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + $action = Action::make('test'); + + UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->apply($action); + + $this->actingAs($user); + $action->record($tenant); + + expect($action->isHidden())->toBeFalse(); + expect($action->isDisabled())->toBeFalse(); + expect($action->getTooltip())->toBeNull(); +}); + +it('supports mixed visibility composition via andVisibleWhen', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $action = Action::make('test'); + + UiEnforcement::for(Capabilities::TENANT_VIEW) + ->andVisibleWhen(fn (): bool => false) + ->apply($action); + + $this->actingAs($user); + + expect($action->isHidden())->toBeTrue(); +}); + +it('supports mixed visibility composition via andHiddenWhen', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $action = Action::make('test'); + + UiEnforcement::for(Capabilities::TENANT_VIEW) + ->andHiddenWhen(fn (): bool => true) + ->apply($action); + + $this->actingAs($user); + + expect($action->isHidden())->toBeTrue(); +}); + +it('disables bulk actions for mixed-authorization selections (capability preflight)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'readonly'], + ]); + + $enforcement = UiEnforcement::for(Capabilities::TENANT_SYNC) + ->tenantFromRecord() + ->preflightByCapability(); + + expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeFalse(); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue(); +}); +