feat: enforce Filament RBAC via UiEnforcement v2
This commit is contained in:
parent
a53bb3f708
commit
95ccc3008c
@ -1056,9 +1056,9 @@ ### Replaced Utilities
|
|||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
|
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
|
||||||
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
|
- PostgreSQL (Sail)
|
||||||
- PHP 8.4.15 (Laravel 12) + Filament v5 + Livewire v4 (059-unified-badges)
|
- Tailwind CSS v4
|
||||||
|
|
||||||
## Recent Changes
|
## 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)
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -34,7 +35,6 @@
|
|||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class BackupSetResource extends Resource
|
class BackupSetResource extends Resource
|
||||||
@ -47,8 +47,7 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
return ($tenant = Tenant::current()) instanceof Tenant
|
return UiEnforcement::for(Capabilities::TENANT_SYNC)->isAllowed();
|
||||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
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]))
|
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('restore')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
->label('Restore')
|
->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false)
|
||||||
->color('success')
|
->apply(
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
Actions\Action::make('restore')
|
||||||
->requiresConfirmation()
|
->label('Restore')
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
->color('success')
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
->requiresConfirmation()
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
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();
|
if ($record->tenant) {
|
||||||
$record->items()->withTrashed()->restore();
|
$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) {
|
Notification::make()
|
||||||
$auditLogger->log(
|
->title('Backup set restored')
|
||||||
tenant: $record->tenant,
|
->success()
|
||||||
action: 'backup.restored',
|
->send();
|
||||||
resourceType: 'backup_set',
|
}),
|
||||||
resourceId: (string) $record->id,
|
),
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
->title('Backup set restored')
|
->andVisibleWhen(fn (?BackupSet $record): bool => $record ? ! $record->trashed() : false)
|
||||||
->success()
|
->apply(
|
||||||
->send();
|
Actions\Action::make('archive')
|
||||||
}),
|
->label('Archive')
|
||||||
Actions\Action::make('archive')
|
->color('danger')
|
||||||
->label('Archive')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->requiresConfirmation()
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
->requiresConfirmation()
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
$record->delete();
|
||||||
|
|
||||||
$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) {
|
Notification::make()
|
||||||
$auditLogger->log(
|
->title('Backup set archived')
|
||||||
tenant: $record->tenant,
|
->success()
|
||||||
action: 'backup.deleted',
|
->send();
|
||||||
resourceType: 'backup_set',
|
}),
|
||||||
resourceId: (string) $record->id,
|
),
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
->title('Backup set archived')
|
->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false)
|
||||||
->success()
|
->apply(
|
||||||
->send();
|
Actions\Action::make('forceDelete')
|
||||||
}),
|
->label('Force delete')
|
||||||
Actions\Action::make('forceDelete')
|
->color('danger')
|
||||||
->label('Force delete')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->requiresConfirmation()
|
||||||
->icon('heroicon-o-trash')
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
->requiresConfirmation()
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot force delete backup set')
|
||||||
|
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
return;
|
||||||
Notification::make()
|
}
|
||||||
->title('Cannot force delete backup set')
|
|
||||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
if ($record->tenant) {
|
||||||
}
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.force_deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
$record->items()->withTrashed()->forceDelete();
|
||||||
$auditLogger->log(
|
$record->forceDelete();
|
||||||
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();
|
Notification::make()
|
||||||
$record->forceDelete();
|
->title('Backup set permanently deleted')
|
||||||
|
->success()
|
||||||
Notification::make()
|
->send();
|
||||||
->title('Backup set permanently deleted')
|
}),
|
||||||
->success()
|
),
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_delete')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
->label('Archive Backup Sets')
|
->andHiddenWhen(function (HasTable $livewire): bool {
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
@ -214,83 +206,80 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $isOnlyTrashed;
|
return $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
->apply(
|
||||||
->form(function (Collection $records) {
|
BulkAction::make('bulk_delete')
|
||||||
if ($records->count() >= 10) {
|
->label('Archive Backup Sets')
|
||||||
return [
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
Forms\Components\TextInput::make('confirmation')
|
->color('danger')
|
||||||
->label('Type DELETE to confirm')
|
->requiresConfirmation()
|
||||||
->required()
|
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
||||||
->in(['DELETE'])
|
->form(function (Collection $records) {
|
||||||
->validationMessages([
|
if ($records->count() >= 10) {
|
||||||
'in' => 'Please type DELETE to confirm.',
|
return [
|
||||||
]),
|
Forms\Components\TextInput::make('confirmation')
|
||||||
];
|
->label('Type DELETE to confirm')
|
||||||
}
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$tenant = Tenant::current();
|
||||||
return;
|
$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 */
|
/** @var OperationRunService $runs */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$runs = app(OperationRunService::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
$runs = app(OperationRunService::class);
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.delete',
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
targetScope: [
|
||||||
tenant: $tenant,
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
type: 'backup_set.delete',
|
],
|
||||||
targetScope: [
|
selectionIdentity: $selectionIdentity,
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
],
|
BulkBackupSetDeleteJob::dispatch(
|
||||||
selectionIdentity: $selectionIdentity,
|
tenantId: (int) $tenant->getKey(),
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
BulkBackupSetDeleteJob::dispatch(
|
backupSetIds: $ids,
|
||||||
tenantId: (int) $tenant->getKey(),
|
operationRun: $operationRun,
|
||||||
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')
|
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
),
|
||||||
|
|
||||||
BulkAction::make('bulk_restore')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
->label('Restore Backup Sets')
|
->andHiddenWhen(function (HasTable $livewire): bool {
|
||||||
->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) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
@ -298,69 +287,66 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
->apply(
|
||||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
BulkAction::make('bulk_restore')
|
||||||
->action(function (Collection $records) {
|
->label('Restore Backup Sets')
|
||||||
$tenant = Tenant::current();
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
$user = auth()->user();
|
->color('success')
|
||||||
$count = $records->count();
|
->requiresConfirmation()
|
||||||
$ids = $records->pluck('id')->toArray();
|
->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) {
|
$tenant = Tenant::current();
|
||||||
return;
|
$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 */
|
/** @var OperationRunService $runs */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$runs = app(OperationRunService::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
$runs = app(OperationRunService::class);
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.restore',
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
targetScope: [
|
||||||
tenant: $tenant,
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
type: 'backup_set.restore',
|
],
|
||||||
targetScope: [
|
selectionIdentity: $selectionIdentity,
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
],
|
BulkBackupSetRestoreJob::dispatch(
|
||||||
selectionIdentity: $selectionIdentity,
|
tenantId: (int) $tenant->getKey(),
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
BulkBackupSetRestoreJob::dispatch(
|
backupSetIds: $ids,
|
||||||
tenantId: (int) $tenant->getKey(),
|
operationRun: $operationRun,
|
||||||
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')
|
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
),
|
||||||
|
|
||||||
BulkAction::make('bulk_force_delete')
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
->label('Force Delete Backup Sets')
|
->andHiddenWhen(function (HasTable $livewire): bool {
|
||||||
->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) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
@ -368,75 +354,78 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
->apply(
|
||||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
BulkAction::make('bulk_force_delete')
|
||||||
->form(function (Collection $records) {
|
->label('Force Delete Backup Sets')
|
||||||
if ($records->count() >= 10) {
|
->icon('heroicon-o-trash')
|
||||||
return [
|
->color('danger')
|
||||||
Forms\Components\TextInput::make('confirmation')
|
->requiresConfirmation()
|
||||||
->label('Type DELETE to confirm')
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||||
->required()
|
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||||
->in(['DELETE'])
|
->form(function (Collection $records) {
|
||||||
->validationMessages([
|
if ($records->count() >= 10) {
|
||||||
'in' => 'Please type DELETE to confirm.',
|
return [
|
||||||
]),
|
Forms\Components\TextInput::make('confirmation')
|
||||||
];
|
->label('Type DELETE to confirm')
|
||||||
}
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$tenant = Tenant::current();
|
||||||
return;
|
$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 */
|
/** @var OperationRunService $runs */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$runs = app(OperationRunService::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
$runs = app(OperationRunService::class);
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.force_delete',
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
targetScope: [
|
||||||
tenant: $tenant,
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
type: 'backup_set.force_delete',
|
],
|
||||||
targetScope: [
|
selectionIdentity: $selectionIdentity,
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
],
|
BulkBackupSetForceDeleteJob::dispatch(
|
||||||
selectionIdentity: $selectionIdentity,
|
tenantId: (int) $tenant->getKey(),
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
BulkBackupSetForceDeleteJob::dispatch(
|
backupSetIds: $ids,
|
||||||
tenantId: (int) $tenant->getKey(),
|
operationRun: $operationRun,
|
||||||
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')
|
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,9 +15,7 @@ class ListBackupSets extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
|
||||||
->disabled(fn (): bool => ! BackupSetResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -24,7 +25,6 @@
|
|||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class BackupItemsRelationManager extends RelationManager
|
class BackupItemsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -131,23 +131,21 @@ public function table(Table $table): Table
|
|||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('addPolicies')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
||||||
->label('Add Policies')
|
Actions\Action::make('addPolicies')
|
||||||
->icon('heroicon-o-plus')
|
->label('Add Policies')
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
->icon('heroicon-o-plus')
|
||||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)))
|
->modalHeading('Add Policies')
|
||||||
->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant
|
->modalSubmitAction(false)
|
||||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.')
|
->modalCancelActionLabel('Close')
|
||||||
->modalHeading('Add Policies')
|
->modalContent(function (): View {
|
||||||
->modalSubmitAction(false)
|
$backupSet = $this->getOwnerRecord();
|
||||||
->modalCancelActionLabel('Close')
|
|
||||||
->modalContent(function (): View {
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
return view('filament.modals.backup-set-policy-picker', [
|
return view('filament.modals.backup-set-policy-picker', [
|
||||||
'backupSetId' => $backupSet->getKey(),
|
'backupSetId' => $backupSet->getKey(),
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -164,174 +162,156 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||||
->openUrlInNewTab(true),
|
->openUrlInNewTab(true),
|
||||||
Actions\Action::make('remove')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
||||||
->label('Remove')
|
Actions\Action::make('remove')
|
||||||
->color('danger')
|
->label('Remove')
|
||||||
->icon('heroicon-o-x-mark')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-x-mark')
|
||||||
->action(function (BackupItem $record): void {
|
->requiresConfirmation()
|
||||||
$backupSet = $this->getOwnerRecord();
|
->action(function (BackupItem $record): void {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
$tenant = $backupSet->tenant;
|
||||||
|
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||||
if (! $user instanceof User) {
|
->tenantFromRecord()
|
||||||
abort(403);
|
->authorizeOrAbort($tenant);
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
/** @var User $user */
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$backupItemIds = [(int) $record->getKey()];
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
/** @var OperationRunService $opService */
|
||||||
abort(403);
|
$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) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
abort(403);
|
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->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
$opService = app(OperationRunService::class);
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
$opRun = $opService->ensureRun(
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
tenant: $tenant,
|
backupItemIds: $backupItemIds,
|
||||||
type: 'backup_set.remove_policies',
|
initiatorUserId: (int) $user->getKey(),
|
||||||
inputs: [
|
operationRun: $opRun,
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
);
|
||||||
'backup_item_ids' => $backupItemIds,
|
});
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
Notification::make()
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
|
||||||
->info()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->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'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
Actions\BulkAction::make('bulk_remove')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
||||||
->label('Remove selected')
|
Actions\BulkAction::make('bulk_remove')
|
||||||
->icon('heroicon-o-x-mark')
|
->label('Remove selected')
|
||||||
->color('danger')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->color('danger')
|
||||||
->deselectRecordsAfterCompletion()
|
->requiresConfirmation()
|
||||||
->action(function (Collection $records): void {
|
->deselectRecordsAfterCompletion()
|
||||||
if ($records->isEmpty()) {
|
->action(function (Collection $records): void {
|
||||||
return;
|
if ($records->isEmpty()) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
$tenant = $backupSet->tenant;
|
||||||
|
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||||
if (! $user instanceof User) {
|
->tenantFromRecord()
|
||||||
abort(403);
|
->authorizeOrAbort($tenant);
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
/** @var User $user */
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$backupItemIds = $records
|
||||||
abort(404);
|
->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)) {
|
if ($backupItemIds === []) {
|
||||||
abort(403);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
/** @var OperationRunService $opService */
|
||||||
abort(403);
|
$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
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
->pluck('id')
|
Notification::make()
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
->title('Removal already queued')
|
||||||
->filter(fn (int $value): bool => $value > 0)
|
->body('A matching remove operation is already queued or running.')
|
||||||
->unique()
|
->info()
|
||||||
->sort()
|
->actions([
|
||||||
->values()
|
Actions\Action::make('view_run')
|
||||||
->all();
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
if ($backupItemIds === []) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
$opService = app(OperationRunService::class);
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
$opRun = $opService->ensureRun(
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
tenant: $tenant,
|
backupItemIds: $backupItemIds,
|
||||||
type: 'backup_set.remove_policies',
|
initiatorUserId: (int) $user->getKey(),
|
||||||
inputs: [
|
operationRun: $opRun,
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
);
|
||||||
'backup_item_ids' => $backupItemIds,
|
});
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
Notification::make()
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
|
||||||
->info()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->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();
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,11 @@
|
|||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
class ListEntraGroups extends ListRecords
|
||||||
{
|
{
|
||||||
@ -30,80 +30,20 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||||
->visible(fn (): bool => (bool) 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')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->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 {
|
->action(function (): void {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort(403);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
@ -182,7 +122,7 @@ protected function getHeaderActions(): array
|
|||||||
])
|
])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@
|
|||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ListEntraGroupSyncRuns extends ListRecords
|
class ListEntraGroupSyncRuns extends ListRecords
|
||||||
{
|
{
|
||||||
@ -21,49 +21,22 @@ class ListEntraGroupSyncRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('sync_groups')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
||||||
->label('Sync Groups')
|
Action::make('sync_groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Sync Groups')
|
||||||
->color('warning')
|
->icon('heroicon-o-arrow-path')
|
||||||
->visible(function (): bool {
|
->color('warning')
|
||||||
$user = auth()->user();
|
->action(function (): void {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
$user = auth()->user();
|
||||||
return false;
|
$tenant = Tenant::current();
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
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();
|
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
$existing = EntraGroupSyncRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
@ -106,7 +79,8 @@ protected function getHeaderActions(): array
|
|||||||
'run_id' => (int) $run->getKey(),
|
'run_id' => (int) $run->getKey(),
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
]));
|
]));
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -17,6 +18,7 @@
|
|||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -26,7 +28,6 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryItemResource extends Resource
|
class InventoryItemResource extends Resource
|
||||||
@ -43,21 +44,18 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -24,7 +25,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Filament\Support\Enums\Size;
|
use Filament\Support\Enums\Size;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
{
|
{
|
||||||
@ -40,140 +40,91 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('run_inventory_sync')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
||||||
->label('Run Inventory Sync')
|
Action::make('run_inventory_sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Run Inventory Sync')
|
||||||
->color('warning')
|
->icon('heroicon-o-arrow-path')
|
||||||
->form([
|
->color('warning')
|
||||||
Select::make('policy_types')
|
->form([
|
||||||
->label('Policy types')
|
Select::make('policy_types')
|
||||||
->multiple()
|
->label('Policy types')
|
||||||
->searchable()
|
->multiple()
|
||||||
->preload()
|
->searchable()
|
||||||
->native(false)
|
->preload()
|
||||||
->hintActions([
|
->native(false)
|
||||||
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
|
->hintActions([
|
||||||
->label('Select all')
|
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
|
||||||
->link()
|
->label('Select all')
|
||||||
->size(Size::Small)
|
->link()
|
||||||
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
|
->size(Size::Small)
|
||||||
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
|
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
|
||||||
}),
|
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
|
||||||
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
|
}),
|
||||||
->label('Clear')
|
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
|
||||||
->link()
|
->label('Clear')
|
||||||
->size(Size::Small)
|
->link()
|
||||||
->action(function () use ($component): void {
|
->size(Size::Small)
|
||||||
$component->state([]);
|
->action(function () use ($component): void {
|
||||||
}),
|
$component->state([]);
|
||||||
])
|
}),
|
||||||
->options(function (): array {
|
])
|
||||||
return collect(InventoryPolicyTypeMeta::supported())
|
->options(function (): array {
|
||||||
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
return collect(InventoryPolicyTypeMeta::supported())
|
||||||
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
||||||
->mapWithKeys(function ($items, string $category): array {
|
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
||||||
$options = collect($items)
|
->mapWithKeys(function ($items, string $category): array {
|
||||||
->mapWithKeys(function (array $meta): array {
|
$options = collect($items)
|
||||||
$type = (string) $meta['type'];
|
->mapWithKeys(function (array $meta): array {
|
||||||
$label = (string) ($meta['label'] ?? $type);
|
$type = (string) $meta['type'];
|
||||||
$platform = (string) ($meta['platform'] ?? 'all');
|
$label = (string) ($meta['label'] ?? $type);
|
||||||
|
$platform = (string) ($meta['platform'] ?? 'all');
|
||||||
|
|
||||||
return [$type => "{$label} • {$platform}"];
|
return [$type => "{$label} • {$platform}"];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
return [$category => $options];
|
return [$category => $options];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
})
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Toggle::make('include_foundations')
|
Toggle::make('include_foundations')
|
||||||
->label('Include foundation types')
|
->label('Include foundation types')
|
||||||
->helperText('Include scope tags, assignment filters, and notification templates.')
|
->helperText('Include scope tags, assignment filters, and notification templates.')
|
||||||
->default(true)
|
->default(true)
|
||||||
->dehydrated()
|
->dehydrated()
|
||||||
->rules(['boolean'])
|
->rules(['boolean'])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Toggle::make('include_dependencies')
|
Toggle::make('include_dependencies')
|
||||||
->label('Include dependencies')
|
->label('Include dependencies')
|
||||||
->helperText('Include dependency extraction where supported.')
|
->helperText('Include dependency extraction where supported.')
|
||||||
->default(true)
|
->default(true)
|
||||||
->dehydrated()
|
->dehydrated()
|
||||||
->rules(['boolean'])
|
->rules(['boolean'])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Hidden::make('tenant_id')
|
Hidden::make('tenant_id')
|
||||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||||
->dehydrated(),
|
->dehydrated(),
|
||||||
])
|
])
|
||||||
->visible(function (): bool {
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
if (! $tenant instanceof Tenant) {
|
$user = auth()->user();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
})
|
return;
|
||||||
->disabled(function (): bool {
|
}
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||||
if (! $tenant instanceof Tenant) {
|
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||||
return true;
|
Notification::make()
|
||||||
}
|
->title('Not allowed')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, 'Not allowed');
|
||||||
})
|
}
|
||||||
->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');
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||||
if (array_key_exists('policy_types', $data)) {
|
if (array_key_exists('policy_types', $data)) {
|
||||||
@ -277,7 +228,8 @@ protected function getHeaderActions(): array
|
|||||||
->send();
|
->send();
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,13 @@
|
|||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -21,7 +23,6 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventorySyncRunResource extends Resource
|
class InventorySyncRunResource extends Resource
|
||||||
@ -40,21 +41,18 @@ class InventorySyncRunResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -29,7 +30,6 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
@ -55,17 +55,17 @@ public static function form(Schema $schema): Schema
|
|||||||
TextInput::make('display_name')
|
TextInput::make('display_name')
|
||||||
->label('Display name')
|
->label('Display name')
|
||||||
->required()
|
->required()
|
||||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed())
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra tenant ID')
|
->label('Entra tenant ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed())
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
Toggle::make('is_default')
|
Toggle::make('is_default')
|
||||||
->label('Default connection')
|
->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.'),
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
@ -146,55 +146,25 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
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')
|
->label('Check connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -252,55 +222,23 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('inventory_sync')
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('inventory_sync')
|
||||||
->label('Inventory sync')
|
->label('Inventory sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -358,55 +296,23 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('compliance_snapshot')
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('compliance_snapshot')
|
||||||
->label('Compliance snapshot')
|
->label('Compliance snapshot')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -464,19 +370,21 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('set_default')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('set_default')
|
||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
&& $record->status !== 'disabled'
|
|
||||||
&& ! $record->is_default)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$record->makeDefault();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -506,14 +414,13 @@ public static function table(Table $table): Table
|
|||||||
->title('Default connection updated')
|
->title('Default connection updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('update_credentials')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('update_credentials')
|
||||||
->label('Update credentials')
|
->label('Update credentials')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
@ -526,9 +433,13 @@ public static function table(Table $table): Table
|
|||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -562,18 +473,21 @@ public static function table(Table $table): Table
|
|||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('enable_connection')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('enable_connection')
|
||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
&& $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -626,19 +540,22 @@ public static function table(Table $table): Table
|
|||||||
->title('Provider connection enabled')
|
->title('Provider connection enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
|
|
||||||
Actions\Action::make('disable_connection')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('disable_connection')
|
||||||
->label('Disable connection')
|
->label('Disable connection')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
&& $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
@ -673,7 +590,7 @@ public static function table(Table $table): Table
|
|||||||
->title('Provider connection disabled')
|
->title('Provider connection disabled')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -21,7 +22,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class EditProviderConnection extends EditRecord
|
class EditProviderConnection extends EditRecord
|
||||||
{
|
{
|
||||||
@ -108,89 +108,67 @@ protected function afterSave(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make()
|
Actions\DeleteAction::make()
|
||||||
->visible(false),
|
->visible(false),
|
||||||
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Action::make('view_last_check_run')
|
UiEnforcement::for(Capabilities::PROVIDER_VIEW)
|
||||||
->label('View last check run')
|
->andVisibleWhen(function (ProviderConnection $record): bool {
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
|
|
||||||
&& OperationRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('type', 'provider.connection.check')
|
|
||||||
->where('context->provider_connection_id', (int) $record->getKey())
|
|
||||||
->exists())
|
|
||||||
->url(function (ProviderConnection $record): ?string {
|
|
||||||
$tenant = Tenant::current();
|
$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
|
return $tenant instanceof Tenant
|
||||||
&& $user instanceof User
|
&& OperationRun::query()
|
||||||
&& $user->canAccessTenant($tenant)
|
->where('tenant_id', $tenant->getKey())
|
||||||
&& $record->status !== 'disabled';
|
->where('type', 'provider.connection.check')
|
||||||
|
->where('context->provider_connection_id', (int) $record->getKey())
|
||||||
|
->exists();
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
->apply(
|
||||||
$tenant = Tenant::current();
|
Action::make('view_last_check_run')
|
||||||
$user = auth()->user();
|
->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) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
$run = OperationRun::query()
|
||||||
})
|
->where('tenant_id', $tenant->getKey())
|
||||||
->tooltip(function (): ?string {
|
->where('type', 'provider.connection.check')
|
||||||
$tenant = Tenant::current();
|
->where('context->provider_connection_id', (int) $record->getKey())
|
||||||
$user = auth()->user();
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $run instanceof OperationRun) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
return OperationRunLinks::view($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);
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)
|
||||||
abort_unless($user instanceof User, 403);
|
->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
->apply(
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
Action::make('check_connection')
|
||||||
$initiator = $user;
|
->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(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -247,29 +225,34 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('update_credentials')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(
|
||||||
->label('Update credentials')
|
Action::make('update_credentials')
|
||||||
->icon('heroicon-o-key')
|
->label('Update credentials')
|
||||||
->color('primary')
|
->icon('heroicon-o-key')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->color('primary')
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant))
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('client_secret')
|
TextInput::make('client_secret')
|
||||||
->label('Client secret')
|
->label('Client secret')
|
||||||
->password()
|
->password()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
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(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -303,24 +286,34 @@ protected function getHeaderActions(): array
|
|||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('set_default')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)
|
||||||
->label('Set as default')
|
->andVisibleWhen(function (ProviderConnection $record): bool {
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->color('primary')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
|
||||||
&& $record->status !== 'disabled'
|
|
||||||
&& ! $record->is_default
|
|
||||||
&& ProviderConnection::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('provider', $record->provider)
|
|
||||||
->count() > 1)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
$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();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -350,52 +343,27 @@ protected function getHeaderActions(): array
|
|||||||
->title('Default connection updated')
|
->title('Default connection updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('inventory_sync')
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)
|
||||||
->label('Inventory sync')
|
->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->icon('heroicon-o-arrow-path')
|
->apply(
|
||||||
->color('info')
|
Action::make('inventory_sync')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->label('Inventory sync')
|
||||||
$tenant = Tenant::current();
|
->icon('heroicon-o-arrow-path')
|
||||||
$user = auth()->user();
|
->color('info')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$tenant = Tenant::current();
|
||||||
&& $user instanceof User
|
$user = auth()->user();
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
$initiator = $user;
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -452,52 +420,27 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('compliance_snapshot')
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)
|
||||||
->label('Compliance snapshot')
|
->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->icon('heroicon-o-shield-check')
|
->apply(
|
||||||
->color('info')
|
Action::make('compliance_snapshot')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->label('Compliance snapshot')
|
||||||
$tenant = Tenant::current();
|
->icon('heroicon-o-shield-check')
|
||||||
$user = auth()->user();
|
->color('info')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$tenant = Tenant::current();
|
||||||
&& $user instanceof User
|
$user = auth()->user();
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
$initiator = $user;
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -554,19 +497,24 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('enable_connection')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)
|
||||||
->label('Enable connection')
|
->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
->icon('heroicon-o-play')
|
->apply(
|
||||||
->color('success')
|
Action::make('enable_connection')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->label('Enable connection')
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
->icon('heroicon-o-play')
|
||||||
&& $record->status === 'disabled')
|
->color('success')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
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();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -619,20 +567,25 @@ protected function getHeaderActions(): array
|
|||||||
->title('Provider connection enabled')
|
->title('Provider connection enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
Action::make('disable_connection')
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)
|
||||||
->label('Disable connection')
|
->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->apply(
|
||||||
->color('danger')
|
Action::make('disable_connection')
|
||||||
->requiresConfirmation()
|
->label('Disable connection')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
->color('danger')
|
||||||
&& $record->status !== 'disabled')
|
->requiresConfirmation()
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
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;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
@ -667,7 +620,8 @@ protected function getHeaderActions(): array
|
|||||||
->title('Provider connection disabled')
|
->title('Provider connection disabled')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
@ -677,9 +631,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
protected function getFormActions(): array
|
protected function getFormActions(): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
if (UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) {
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) {
|
|
||||||
return parent::getFormActions();
|
return parent::getFormActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -690,9 +642,7 @@ protected function getFormActions(): array
|
|||||||
|
|
||||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
return parent::handleRecordUpdate($record, $data);
|
return parent::handleRecordUpdate($record, $data);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,11 +15,9 @@ class ListProviderConnections extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(
|
||||||
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
|
Actions\CreateAction::make(),
|
||||||
->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.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -50,7 +51,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -65,8 +65,7 @@ class RestoreRunResource extends Resource
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
return ($tenant = Tenant::current()) instanceof Tenant
|
return UiEnforcement::for(Capabilities::TENANT_MANAGE)->isAllowed();
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -748,7 +747,7 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('rerun')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('rerun')
|
||||||
->label('Rerun')
|
->label('Rerun')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
@ -761,17 +760,13 @@ public static function table(Table $table): Table
|
|||||||
&& $backupSet !== null
|
&& $backupSet !== null
|
||||||
&& ! $backupSet->trashed();
|
&& ! $backupSet->trashed();
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (
|
->action(function (
|
||||||
RestoreRun $record,
|
RestoreRun $record,
|
||||||
RestoreService $restoreService,
|
RestoreService $restoreService,
|
||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
HasTable $livewire
|
HasTable $livewire
|
||||||
) {
|
) {
|
||||||
$currentTenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
@ -932,19 +927,15 @@ public static function table(Table $table): Table
|
|||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
Actions\Action::make('restore')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->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) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
@ -963,19 +954,15 @@ public static function table(Table $table): Table
|
|||||||
->title('Restore run restored')
|
->title('Restore run restored')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
->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) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
if (! $record->isDeletable()) {
|
if (! $record->isDeletable()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -1004,19 +991,15 @@ public static function table(Table $table): Table
|
|||||||
->title('Restore run archived')
|
->title('Restore run archived')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
Actions\Action::make('forceDelete')
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->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) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -1035,18 +1018,16 @@ public static function table(Table $table): Table
|
|||||||
->title('Restore run permanently deleted')
|
->title('Restore run permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_delete')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(BulkAction::make('bulk_delete')
|
||||||
->label('Archive Restore Runs')
|
->label('Archive Restore Runs')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1071,17 +1052,13 @@ public static function table(Table $table): Table
|
|||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$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;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1121,15 +1098,13 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion()),
|
||||||
|
|
||||||
BulkAction::make('bulk_restore')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(BulkAction::make('bulk_restore')
|
||||||
->label('Restore Restore Runs')
|
->label('Restore Restore Runs')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1141,17 +1116,13 @@ public static function table(Table $table): Table
|
|||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
->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.')
|
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$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;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1202,15 +1173,13 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->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')
|
->label('Force Delete Restore Runs')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1231,17 +1200,13 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$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;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1292,7 +1257,7 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion()),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -1491,17 +1456,15 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
|||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
{
|
{
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
/** @var BackupSet $backupSet */
|
/** @var BackupSet $backupSet */
|
||||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
$backupSet = BackupSet::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
if ($backupSet->tenant_id !== $tenant->id) {
|
->findOrFail($data['backup_set_id']);
|
||||||
abort(403, 'Backup set does not belong to the active tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var RestoreService $service */
|
/** @var RestoreService $service */
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
|
|||||||
@ -6,11 +6,11 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
@ -21,9 +21,7 @@ class CreateRestoreRun extends CreateRecord
|
|||||||
|
|
||||||
protected function authorizeAccess(): void
|
protected function authorizeAccess(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSteps(): array
|
public function getSteps(): array
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -13,9 +15,7 @@ class ListRestoreRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make()
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
|
||||||
->disabled(fn (): bool => ! RestoreRunResource::canCreate())
|
|
||||||
->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -43,7 +44,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@ -73,24 +73,16 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->isAllowed($record);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->isAllowed($record);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
@ -106,36 +98,30 @@ public static function canDeleteAny(): bool
|
|||||||
|
|
||||||
private static function userCanManageAnyTenant(User $user): 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
return $user->tenants()
|
||||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
->withTrashed()
|
||||||
return true;
|
->wherePivotIn('role', $roles)
|
||||||
}
|
->exists();
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanDeleteAnyTenant(User $user): bool
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
return $user->tenants()
|
||||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) {
|
->withTrashed()
|
||||||
return true;
|
->wherePivotIn('role', $roles)
|
||||||
}
|
->exists();
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -274,49 +260,19 @@ public static function table(Table $table): Table
|
|||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
->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')
|
->label('Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(function (Tenant $record): bool {
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
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.';
|
|
||||||
})
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
->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();
|
$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 */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -337,7 +293,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: 'policy.sync',
|
type: 'policy.sync',
|
||||||
inputs: $inputs,
|
inputs: $inputs,
|
||||||
initiator: auth()->user()
|
initiator: $user
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||||
@ -350,7 +306,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: 'policy.sync',
|
type: 'policy.sync',
|
||||||
inputs: $inputs,
|
inputs: $inputs,
|
||||||
initiator: auth()->user()
|
initiator: $user
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,44 +346,29 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($opRun, $record)),
|
->url(OperationRunLinks::view($opRun, $record)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
Actions\Action::make('openTenant')
|
Actions\Action::make('openTenant')
|
||||||
->label('Open')
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-right')
|
->icon('heroicon-o-arrow-right')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
Actions\Action::make('edit')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(
|
||||||
->label('Edit')
|
Actions\Action::make('edit')
|
||||||
->icon('heroicon-o-pencil-square')
|
->label('Edit')
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
->icon('heroicon-o-pencil-square')
|
||||||
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $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_DELETE)->tenantFromRecord()->apply(Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->successNotificationTitle('Tenant reactivated')
|
->successNotificationTitle('Tenant reactivated')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
->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 {
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->authorizeOrAbort($record);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
@ -439,54 +380,25 @@ public static function table(Table $table): Table
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
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')
|
->label('Admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
->disabled(function (Tenant $record): bool {
|
->openUrlInNewTab()),
|
||||||
$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(),
|
|
||||||
Actions\Action::make('open_in_entra')
|
Actions\Action::make('open_in_entra')
|
||||||
->label('Open in Entra')
|
->label('Open in Entra')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
Actions\Action::make('verify')
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('verify')
|
||||||
->label('Verify configuration')
|
->label('Verify configuration')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->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 (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
TenantConfigService $configService,
|
||||||
@ -494,44 +406,23 @@ public static function table(Table $table): Table
|
|||||||
RbacHealthService $rbacHealthService,
|
RbacHealthService $rbacHealthService,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->authorizeOrAbort($record);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
}),
|
})),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('archive')
|
||||||
->label('Deactivate')
|
->label('Deactivate')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->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) {
|
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->authorizeOrAbort($record);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
@ -549,40 +440,21 @@ public static function table(Table $table): Table
|
|||||||
->body('The tenant has been archived and hidden from lists.')
|
->body('The tenant has been archived and hidden from lists.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
Actions\Action::make('forceDelete')
|
UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
->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) {
|
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||||
if ($record === null) {
|
if ($record === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->authorizeOrAbort($record);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::withTrashed()->find($record->id);
|
$tenant = Tenant::withTrashed()->find($record->id);
|
||||||
|
|
||||||
@ -610,107 +482,100 @@ public static function table(Table $table): Table
|
|||||||
->title('Tenant permanently deleted')
|
->title('Tenant permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkAction::make('syncSelected')
|
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||||
->label('Sync selected')
|
->tenantFromRecord()
|
||||||
->icon('heroicon-o-arrow-path')
|
->preflightByCapability()
|
||||||
->color('warning')
|
->apply(Actions\BulkAction::make('syncSelected')
|
||||||
->requiresConfirmation()
|
->label('Sync selected')
|
||||||
->visible(function (): bool {
|
->icon('heroicon-o-arrow-path')
|
||||||
$user = auth()->user();
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||||
|
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
||||||
|
->tenantFromRecord()
|
||||||
|
->authorizeBulkSelectionOrAbort($records);
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
/** @var User $user */
|
||||||
return false;
|
$user = auth()->user();
|
||||||
}
|
|
||||||
|
|
||||||
return $user->tenants()
|
$eligible = $records
|
||||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
->filter(fn ($record) => $record instanceof Tenant && $record->isActive());
|
||||||
->exists();
|
|
||||||
})
|
|
||||||
->authorize(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if ($eligible->isEmpty()) {
|
||||||
return false;
|
Notification::make()
|
||||||
}
|
->title('Bulk sync skipped')
|
||||||
|
->body('No eligible tenants selected.')
|
||||||
|
->icon('heroicon-o-information-circle')
|
||||||
|
->info()
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
return $user->tenants()
|
return;
|
||||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
}
|
||||||
->exists();
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if ($eligible->count() !== $records->count()) {
|
||||||
return;
|
$skipped = $records->count() - $eligible->count();
|
||||||
}
|
$total = $records->count();
|
||||||
|
|
||||||
$eligible = $records
|
Notification::make()
|
||||||
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
->title('Some tenants were skipped')
|
||||||
->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
->body("Skipped {$skipped} of {$total} selected tenants (inactive).")
|
||||||
|
->warning()
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
if ($eligible->isEmpty()) {
|
$tenantContext = Tenant::current() ?? $eligible->first();
|
||||||
Notification::make()
|
|
||||||
->title('Bulk sync skipped')
|
if (! $tenantContext) {
|
||||||
->body('No eligible tenants selected.')
|
return;
|
||||||
->icon('heroicon-o-information-circle')
|
}
|
||||||
->info()
|
|
||||||
->sendToDatabase($user)
|
$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();
|
->send();
|
||||||
|
})
|
||||||
return;
|
->deselectRecordsAfterCompletion()),
|
||||||
}
|
|
||||||
|
|
||||||
$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(),
|
|
||||||
])
|
])
|
||||||
->headerActions([]);
|
->headerActions([]);
|
||||||
}
|
}
|
||||||
@ -803,7 +668,7 @@ public static function getRelations(): array
|
|||||||
public static function rbacAction(): Actions\Action
|
public static function rbacAction(): Actions\Action
|
||||||
{
|
{
|
||||||
// ... [RBAC Action Omitted - No Change] ...
|
// ... [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')
|
->label('Setup Intune RBAC')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
@ -886,15 +751,6 @@ public static function rbacAction(): Actions\Action
|
|||||||
->loadingMessage('Searching groups...'),
|
->loadingMessage('Searching groups...'),
|
||||||
])
|
])
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->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()
|
->requiresConfirmation()
|
||||||
->action(function (
|
->action(function (
|
||||||
array $data,
|
array $data,
|
||||||
@ -902,15 +758,9 @@ public static function rbacAction(): Actions\Action
|
|||||||
RbacOnboardingService $service,
|
RbacOnboardingService $service,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
$user = auth()->user();
|
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
||||||
|
->tenantFromRecord()
|
||||||
if (! $user instanceof User) {
|
->authorizeOrAbort($record);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
@ -989,7 +839,7 @@ public static function rbacAction(): Actions\Action
|
|||||||
->body($result['message'] ?? 'Unknown error')
|
->body($result['message'] ?? 'Unknown error')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||||
|
|||||||
@ -4,11 +4,10 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditTenant extends EditRecord
|
||||||
{
|
{
|
||||||
@ -18,42 +17,24 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\Action::make('archive')
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
->label('Archive')
|
->tenantFromRecord()
|
||||||
->color('danger')
|
->apply(
|
||||||
->requiresConfirmation()
|
Actions\Action::make('archive')
|
||||||
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
->label('Archive')
|
||||||
->disabled(function (): bool {
|
->color('danger')
|
||||||
$tenant = $this->record;
|
->requiresConfirmation()
|
||||||
$user = auth()->user();
|
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
||||||
|
->action(function (): void {
|
||||||
|
$tenant = $this->record;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
||||||
return true;
|
->tenantFromRecord()
|
||||||
}
|
->authorizeOrAbort($tenant);
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
|
$tenant->delete();
|
||||||
})
|
}),
|
||||||
->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();
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
526
app/Support/Auth/UiEnforcement.php
Normal file
526
app/Support/Auth/UiEnforcement.php
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class UiEnforcement
|
||||||
|
{
|
||||||
|
private const TENANT_RESOLVER_FILAMENT = 'filament';
|
||||||
|
|
||||||
|
private const TENANT_RESOLVER_RECORD = 'record';
|
||||||
|
|
||||||
|
private const TENANT_RESOLVER_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_CAPABILITY = 'capability';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_TENANT_MEMBERSHIP = 'tenant_membership';
|
||||||
|
|
||||||
|
private const BULK_PREFLIGHT_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
private bool $preserveVisibility = false;
|
||||||
|
|
||||||
|
private ?\Closure $businessVisible = null;
|
||||||
|
|
||||||
|
private ?\Closure $businessHidden = null;
|
||||||
|
|
||||||
|
private string $tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
|
||||||
|
|
||||||
|
private ?\Closure $customTenantResolver = null;
|
||||||
|
|
||||||
|
private string $bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Closure(Collection<int, Model>): 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<int, Model> $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<int, Model> $records */
|
||||||
|
$records = collect($action->getSelectedRecords());
|
||||||
|
|
||||||
|
return $this->bulkIsDisabled($records);
|
||||||
|
});
|
||||||
|
|
||||||
|
$action->tooltip(function () use ($action): ?string {
|
||||||
|
/** @var Collection<int, Model> $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<int, Model> $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<int, Model> $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<int, Model> $records
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
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<int> $tenantIds
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function membershipTenantIds(User $user, array $tenantIds): array
|
||||||
|
{
|
||||||
|
/** @var array<int> $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<int> $tenantIds
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function capabilityTenantIds(User $user, array $tenantIds): array
|
||||||
|
{
|
||||||
|
$roles = RoleCapabilityMap::rolesWithCapability($this->capability);
|
||||||
|
|
||||||
|
if ($roles === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int> $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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Support/Auth/UiTooltips.php
Normal file
14
app/Support/Auth/UiTooltips.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
class UiTooltips
|
||||||
|
{
|
||||||
|
public const INSUFFICIENT_PERMISSION_ASK_OWNER = 'Insufficient permission — ask a tenant Owner.';
|
||||||
|
|
||||||
|
public static function insufficientPermission(): string
|
||||||
|
{
|
||||||
|
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -18,6 +19,7 @@
|
|||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
|||||||
71
tests/Feature/Filament/BackupSetUiEnforcementTest.php
Normal file
71
tests/Feature/Filament/BackupSetUiEnforcementTest.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-members are denied access to BackupSet tenant routes (404)', function () {
|
||||||
|
$tenant = Tenant::factory()->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();
|
||||||
|
});
|
||||||
|
|
||||||
@ -7,13 +7,19 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
test('entra group sync runs are listed for the active tenant', function () {
|
test('entra group sync runs are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
@ -96,3 +102,22 @@
|
|||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
|
->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();
|
||||||
|
});
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
test('inventory items are listed for the active tenant', function () {
|
test('inventory items are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
@ -39,3 +48,28 @@
|
|||||||
->assertSee('Item A')
|
->assertSee('Item A')
|
||||||
->assertDontSee('Item B');
|
->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());
|
||||||
|
});
|
||||||
|
|||||||
@ -4,9 +4,14 @@
|
|||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
test('inventory sync runs are listed for the active tenant', function () {
|
test('inventory sync runs are listed for the active tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
@ -35,3 +40,14 @@
|
|||||||
->assertSee(str_repeat('a', 12))
|
->assertSee(str_repeat('a', 12))
|
||||||
->assertDontSee(str_repeat('b', 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);
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-members are denied access to provider connection tenant routes (404)', function () {
|
||||||
|
$tenant = Tenant::factory()->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');
|
||||||
|
});
|
||||||
83
tests/Feature/Filament/RestoreRunUiEnforcementTest.php
Normal file
83
tests/Feature/Filament/RestoreRunUiEnforcementTest.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-members are denied access to RestoreRun tenant routes (404)', function () {
|
||||||
|
$tenant = Tenant::factory()->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();
|
||||||
|
});
|
||||||
|
|
||||||
@ -4,13 +4,19 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
test('readonly users may switch current tenant via ChooseTenant', function () {
|
test('readonly users may switch current tenant via ChooseTenant', function () {
|
||||||
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
|
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
@ -57,6 +63,7 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionDisabled('archive', $tenant)
|
->assertTableActionDisabled('archive', $tenant)
|
||||||
|
->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant)
|
||||||
->callTableAction('archive', $tenant);
|
->callTableAction('archive', $tenant);
|
||||||
|
|
||||||
expect($tenant->fresh()->trashed())->toBeFalse();
|
expect($tenant->fresh()->trashed())->toBeFalse();
|
||||||
@ -74,6 +81,7 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionDisabled('forceDelete', $tenant)
|
->assertTableActionDisabled('forceDelete', $tenant)
|
||||||
|
->assertTableActionExists('forceDelete', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant)
|
||||||
->callTableAction('forceDelete', $tenant);
|
->callTableAction('forceDelete', $tenant);
|
||||||
|
|
||||||
expect(Tenant::withTrashed()->find($tenant->getKey()))->not->toBeNull();
|
expect(Tenant::withTrashed()->find($tenant->getKey()))->not->toBeNull();
|
||||||
@ -89,6 +97,7 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionDisabled('verify', $tenant)
|
->assertTableActionDisabled('verify', $tenant)
|
||||||
|
->assertTableActionExists('verify', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant)
|
||||||
->callTableAction('verify', $tenant);
|
->callTableAction('verify', $tenant);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,7 +122,8 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->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 () {
|
test('readonly users cannot open admin consent', function () {
|
||||||
@ -126,7 +136,8 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->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 () {
|
test('readonly users cannot start tenant sync from tenant menu', function () {
|
||||||
@ -138,5 +149,6 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionDisabled('syncTenant', $tenant);
|
->assertTableActionDisabled('syncTenant', $tenant)
|
||||||
|
->assertTableActionExists('syncTenant', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,14 +4,20 @@
|
|||||||
use App\Jobs\BulkTenantSyncJob;
|
use App\Jobs\BulkTenantSyncJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
});
|
||||||
|
|
||||||
test('tenant-scoped pages return 404 for unauthorized tenant', function () {
|
test('tenant-scoped pages return 404 for unauthorized tenant', function () {
|
||||||
[$user, $authorizedTenant] = createUserWithTenant();
|
[$user, $authorizedTenant] = createUserWithTenant();
|
||||||
$unauthorizedTenant = Tenant::factory()->create();
|
$unauthorizedTenant = Tenant::factory()->create();
|
||||||
@ -21,6 +27,40 @@
|
|||||||
->assertNotFound();
|
->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 () {
|
test('tenant portfolio lists only tenants the user can access', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$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();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@ -87,8 +129,48 @@
|
|||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ListTenants::class)
|
$livewire = Livewire::actingAs($user)
|
||||||
->assertTableBulkActionHidden('syncSelected');
|
->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 () {
|
test('tenant set event updates user tenant preference last used timestamp', function () {
|
||||||
|
|||||||
118
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal file
118
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
it('does not introduce ad-hoc authorization patterns in app/Filament (allowlist-driven)', function () {
|
||||||
|
$root = base_path();
|
||||||
|
$self = realpath(__FILE__);
|
||||||
|
|
||||||
|
$directories = [
|
||||||
|
$root.'/app/Filament',
|
||||||
|
];
|
||||||
|
|
||||||
|
$excludedPaths = [
|
||||||
|
$root.'/vendor',
|
||||||
|
$root.'/storage',
|
||||||
|
$root.'/specs',
|
||||||
|
$root.'/spechistory',
|
||||||
|
$root.'/references',
|
||||||
|
$root.'/public/build',
|
||||||
|
];
|
||||||
|
|
||||||
|
$allowlist = [
|
||||||
|
// NOTE: Shrink this list as files are migrated to UiEnforcement (Feature 066b).
|
||||||
|
'app/Filament/Pages/ChooseTenant.php',
|
||||||
|
'app/Filament/Pages/DriftLanding.php',
|
||||||
|
'app/Filament/Pages/Tenancy/RegisterTenant.php',
|
||||||
|
'app/Filament/Resources/BackupScheduleResource.php',
|
||||||
|
'app/Filament/Resources/FindingResource.php',
|
||||||
|
'app/Filament/Resources/FindingResource/Pages/ListFindings.php',
|
||||||
|
'app/Filament/Resources/PolicyResource.php',
|
||||||
|
'app/Filament/Resources/PolicyResource/Pages/ListPolicies.php',
|
||||||
|
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
||||||
|
'app/Filament/Resources/PolicyVersionResource.php',
|
||||||
|
'app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php',
|
||||||
|
'app/Filament/System/Pages/Dashboard.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$forbiddenPatterns = [
|
||||||
|
'/\\bGate::\\b/',
|
||||||
|
'/\\babort\\s*\\(/',
|
||||||
|
'/\\babort_(?:if|unless)\\b/',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var Collection<int, string> $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)
|
||||||
|
);
|
||||||
|
});
|
||||||
36
tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php
Normal file
36
tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('preflights bulk selections with a set-based tenant_memberships query (no N+1)', function () {
|
||||||
|
$tenants = Tenant::factory()->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);
|
||||||
|
});
|
||||||
|
|
||||||
128
tests/Unit/Auth/UiEnforcementTest.php
Normal file
128
tests/Unit/Auth/UiEnforcementTest.php
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiEnforcement;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('forbids preserveVisibility on record-scoped tenant resolution', function () {
|
||||||
|
expect(fn () => 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();
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user