feat(066): centralize RBAC UI enforcement with UiEnforcement
This commit is contained in:
parent
cb932e380b
commit
538c6ea268
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
@ -21,7 +22,10 @@ coverage/
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework
|
||||
/storage/logs
|
||||
/vendor
|
||||
/bootstrap/cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
@ -21,7 +22,6 @@
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class DriftLanding extends Page
|
||||
@ -175,7 +175,10 @@ public function mount(): void
|
||||
}
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
||||
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class RegisterTenant extends BaseRegisterTenant
|
||||
{
|
||||
@ -33,8 +33,11 @@ public static function canView(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -88,7 +91,9 @@ public function form(Schema $schema): Schema
|
||||
*/
|
||||
protected function handleRegistration(array $data): Model
|
||||
{
|
||||
abort_unless(static::canView(), 403);
|
||||
if (! static::canView()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::create($data);
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -23,6 +24,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeZone;
|
||||
@ -50,7 +52,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
@ -65,19 +66,32 @@ public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -92,32 +106,64 @@ public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -315,21 +361,22 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Action::make('runNow')
|
||||
->label('Run now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||
})
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
@ -445,22 +492,26 @@ public static function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||
})
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$userId = auth()->id();
|
||||
@ -576,40 +627,40 @@ public static function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
EditAction::make()
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
DeleteAction::make()
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_run_now')
|
||||
->label('Run now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
@ -731,22 +782,26 @@ public static function table(Table $table): Table
|
||||
if (count($createdRunIds) > 0) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
->title('No tenant selected')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
@ -868,14 +923,15 @@ public static function table(Table $table): Table
|
||||
if (count($createdRunIds) > 0) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
DeleteBulkAction::make('bulk_delete')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\OperationRunService;
|
||||
@ -19,11 +20,13 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -34,7 +37,6 @@
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
@ -47,8 +49,18 @@ class BackupSetResource extends Resource
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return ($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant);
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -90,18 +102,15 @@ public static function table(Table $table): Table
|
||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
@ -121,19 +130,20 @@ public static function table(Table $table): Table
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||
->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);
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$record->delete();
|
||||
|
||||
@ -152,19 +162,20 @@ public static function table(Table $table): Table
|
||||
->title('Backup set archived')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->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);
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
@ -194,18 +205,21 @@ public static function table(Table $table): Table
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -240,8 +254,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -282,14 +294,16 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Backup Sets')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -310,8 +324,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -352,14 +364,16 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Backup Sets')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -395,8 +409,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -437,6 +449,9 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
@ -24,7 +25,6 @@
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BackupItemsRelationManager extends RelationManager
|
||||
{
|
||||
@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$refreshTable = Actions\Action::make('refreshTable')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
$addPolicies = Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->tooltip('You do not have permission to add policies.')
|
||||
->modalHeading('Add Policies')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (): View {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
return view('filament.modals.backup-set-policy-picker', [
|
||||
'backupSetId' => $backupSet->getKey(),
|
||||
]);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($addPolicies)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to add policies.')
|
||||
->apply();
|
||||
|
||||
$removeItem = Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = [(int) $record->getKey()];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($removeItem)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to remove policies.')
|
||||
->apply();
|
||||
|
||||
$bulkRemove = Actions\BulkAction::make('bulk_remove')
|
||||
->label('Remove selected')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records): void {
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = $records
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
});
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRemove)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to remove policies.')
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->columns([
|
||||
@ -125,29 +318,8 @@ public function table(Table $table): Table
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
Actions\Action::make('refreshTable')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)))
|
||||
->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.')
|
||||
->modalHeading('Add Policies')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (): View {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
return view('filament.modals.backup-set-policy-picker', [
|
||||
'backupSetId' => $backupSet->getKey(),
|
||||
]);
|
||||
}),
|
||||
$refreshTable,
|
||||
$addPolicies,
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
@ -164,174 +336,12 @@ public function table(Table $table): Table
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
Actions\Action::make('remove')
|
||||
->label('Remove')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$backupItemIds = [(int) $record->getKey()];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
$removeItem,
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkActionGroup::make([
|
||||
Actions\BulkAction::make('bulk_remove')
|
||||
->label('Remove selected')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records): void {
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$backupItemIds = $records
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||
RemovePoliciesFromBackupSetJob::dispatch(
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
backupItemIds: $backupItemIds,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
$bulkRemove,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -12,10 +12,10 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
{
|
||||
@ -29,81 +29,19 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => (bool) Tenant::current()),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->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 {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
abort(403);
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
||||
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
// --- Phase 3: Canonical Operation Run Start ---
|
||||
@ -182,7 +120,11 @@ protected function getHeaderActions(): array
|
||||
])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync groups.')
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,10 @@
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListEntraGroupSyncRuns extends ListRecords
|
||||
{
|
||||
@ -21,48 +22,19 @@ class ListEntraGroupSyncRuns extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 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 instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
||||
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
@ -106,7 +78,11 @@ protected function getHeaderActions(): array
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'queued',
|
||||
]));
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,10 +12,13 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -29,7 +32,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingResource extends Resource
|
||||
@ -46,19 +48,34 @@ public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -343,37 +360,20 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('acknowledge_selected')
|
||||
->label('Acknowledge selected')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->authorizeIndividualRecords('update')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = $records->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$acknowledgedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
@ -412,6 +412,10 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -4,15 +4,15 @@
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
@ -21,23 +21,12 @@ class ListFindings extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('acknowledge_all_matching')
|
||||
->label('Acknowledge all matching')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||
->modalDescription(function (): string {
|
||||
$count = $this->getAllMatchingCount();
|
||||
@ -62,13 +51,6 @@ protected function getHeaderActions(): array
|
||||
];
|
||||
})
|
||||
->action(function (array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->buildAllMatchingQuery();
|
||||
$count = (clone $query)->count();
|
||||
|
||||
@ -82,15 +64,10 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = (clone $query)->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$updated = $query->update([
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
'acknowledged_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->deselectAllTableRecords();
|
||||
@ -101,21 +78,26 @@ protected function getHeaderActions(): array
|
||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildAllMatchingQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$query = Finding::query();
|
||||
|
||||
if (! $tenant) {
|
||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('tenant_id', $tenant->getKey());
|
||||
$query->where('tenant_id', (int) $tenantId);
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -26,7 +28,6 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
@ -44,20 +45,34 @@ class InventoryItemResource extends Resource
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
return $capabilityResolver->isMember($user, $tenant)
|
||||
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Action as HintAction;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
@ -24,7 +26,6 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Support\Enums\Size;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
@ -40,6 +41,7 @@ protected function getHeaderWidgets(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Action::make('run_inventory_sync')
|
||||
->label('Run Inventory Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -118,51 +120,12 @@ protected function getHeaderActions(): array
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
})
|
||||
->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 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');
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||
@ -172,7 +135,7 @@ protected function getHeaderActions(): array
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
abort(403, 'Not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||
@ -277,7 +240,12 @@ protected function getHeaderActions(): array
|
||||
->send();
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -21,7 +23,6 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class InventorySyncRunResource extends Resource
|
||||
@ -41,20 +42,31 @@ class InventorySyncRunResource extends Resource
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -41,7 +42,6 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class PolicyResource extends Resource
|
||||
@ -363,19 +363,14 @@ public static function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (Policy $record, HasTable $livewire) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
->action(function (Policy $record): void {
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
@ -383,19 +378,20 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (Policy $record) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
->action(function (Policy $record): void {
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
@ -403,29 +399,19 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Policy $record): bool {
|
||||
if ($record->ignored_at !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||
})
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
@ -438,10 +424,6 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
@ -479,28 +461,34 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export')
|
||||
->label('Export to Backup')
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data) {
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$ids = [(int) $record->getKey()];
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -543,10 +531,16 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Ignore Policies')
|
||||
->icon('heroicon-o-trash')
|
||||
@ -558,16 +552,6 @@ public static function table(Table $table): Table
|
||||
|
||||
return $value === 'ignored';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 20) {
|
||||
return [
|
||||
@ -583,18 +567,16 @@ public static function table(Table $table): Table
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records, array $data) {
|
||||
->action(function (Collection $records): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -641,7 +623,11 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
@ -653,28 +639,20 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! in_array($value, [null, 'ignored'], true);
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire) {
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -739,28 +717,17 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||
$value = $visibilityFilterState['value'] ?? null;
|
||||
|
||||
@ -779,10 +746,6 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ids = $records
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
@ -829,38 +792,34 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -926,6 +885,9 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -11,10 +11,10 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListPolicies extends ListRecords
|
||||
{
|
||||
@ -23,61 +23,17 @@ class ListPolicies extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync from Intune')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user->canAccessTenant($tenant);
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$user = auth()->user();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return ! ($user instanceof User
|
||||
&& $tenant instanceof Tenant
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$user = auth()->user();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! ($user instanceof User && $tenant instanceof Tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to sync policies.';
|
||||
})
|
||||
->action(function (self $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
abort(403);
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$requestedTypes = array_map(
|
||||
@ -125,7 +81,12 @@ protected function getHeaderActions(): array
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync policies.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,17 +5,20 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class VersionsRelationManager extends RelationManager
|
||||
{
|
||||
@ -23,29 +26,10 @@ class VersionsRelationManager extends RelationManager
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('version_number', 'desc')
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('restore_to_intune')
|
||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||
->label('Restore to Intune')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
||||
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
@ -56,8 +40,16 @@ public function table(Table $table): Table
|
||||
])
|
||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Missing tenant or user context.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
@ -73,8 +65,8 @@ public function table(Table $table): Table
|
||||
tenant: $tenant,
|
||||
version: $record,
|
||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
@ -92,7 +84,74 @@ public function table(Table $table): Table
|
||||
->send();
|
||||
|
||||
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||
}),
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($restoreToIntune)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
$restoreToIntune
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('version_number', 'desc')
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
$restoreToIntune,
|
||||
Actions\ViewAction::make()
|
||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use App\Services\Intune\VersionDiff;
|
||||
@ -21,6 +22,7 @@
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions;
|
||||
@ -39,7 +41,6 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class PolicyVersionResource extends Resource
|
||||
@ -183,6 +184,294 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||
->label('Prune Versions')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->form(function (Collection $records) {
|
||||
$fields = [
|
||||
Forms\Components\TextInput::make('retention_days')
|
||||
->label('Retention Days')
|
||||
->helperText('Versions captured within the last N days will be skipped.')
|
||||
->numeric()
|
||||
->required()
|
||||
->default(90)
|
||||
->minValue(1),
|
||||
];
|
||||
|
||||
if ($records->count() >= 20) {
|
||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
})
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.prune',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||
BulkPolicyVersionPruneJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
policyVersionIds: $ids,
|
||||
retentionDays: $retentionDays,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
'retention_days' => $retentionDays,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version prune queued')
|
||||
->body("Queued prune for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion();
|
||||
|
||||
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||
->label('Restore Versions')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion();
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||
->label('Force Delete Versions')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $initiator->getKey(),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version force delete queued')
|
||||
->body("Queued force delete for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion();
|
||||
|
||||
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
||||
@ -208,14 +497,64 @@ public static function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
Actions\ActionGroup::make([
|
||||
Actions\Action::make('restore_via_wizard')
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore_via_wizard')
|
||||
->label('Restore via Wizard')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('primary')
|
||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only'
|
||||
|| ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant);
|
||||
})
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You do not have permission to create restore runs.';
|
||||
}
|
||||
|
||||
@ -225,14 +564,34 @@ public static function table(Table $table): Table
|
||||
|
||||
return null;
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
->action(function (PolicyVersion $record) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
Notification::make()
|
||||
->title('Restore disabled for metadata-only snapshot')
|
||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
@ -312,41 +671,24 @@ public static function table(Table $table): Table
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
]));
|
||||
}),
|
||||
Actions\Action::make('archive')
|
||||
});
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
@ -365,41 +707,30 @@ public static function table(Table $table): Table
|
||||
->title('Policy version archived')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('forceDelete')
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
@ -418,42 +749,31 @@ public static function table(Table $table): Table
|
||||
->title('Policy version permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
});
|
||||
|
||||
Actions\Action::make('restore')
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||
->disabled(function (PolicyVersion $record): bool {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
@ -472,350 +792,23 @@ public static function table(Table $table): Table
|
||||
->title('Policy version restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_prune_versions')
|
||||
->label('Prune Versions')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->form(function (Collection $records) {
|
||||
$fields = [
|
||||
Forms\Components\TextInput::make('retention_days')
|
||||
->label('Retention Days')
|
||||
->helperText('Versions captured within the last N days will be skipped.')
|
||||
->numeric()
|
||||
->required()
|
||||
->default(90)
|
||||
->minValue(1),
|
||||
];
|
||||
|
||||
if ($records->count() >= 20) {
|
||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
})
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.prune',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||
BulkPolicyVersionPruneJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
retentionDays: $retentionDays,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
'retention_days' => $retentionDays,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($initiator instanceof User) {
|
||||
Notification::make()
|
||||
->title('Policy version prune queued')
|
||||
->body("Queued prune for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_restore_versions')
|
||||
->label('Restore Versions')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_force_delete_versions')
|
||||
->label('Force Delete Versions')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to manage policy versions.';
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($initiator instanceof User) {
|
||||
Notification::make()
|
||||
->title('Policy version force delete queued')
|
||||
->body("Queued force delete for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
$bulkPruneVersions,
|
||||
$bulkRestoreVersions,
|
||||
$bulkForceDeleteVersions,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
@ -18,6 +19,7 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -29,7 +31,6 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'display_name';
|
||||
|
||||
protected static function hasTenantCapability(string $capability): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema
|
||||
TextInput::make('display_name')
|
||||
->label('Display name')
|
||||
->required()
|
||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->maxLength(255),
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Entra tenant ID')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->rules(['uuid']),
|
||||
Toggle::make('is_default')
|
||||
->label('Default connection')
|
||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||
TextInput::make('status')
|
||||
->label('Status')
|
||||
@ -146,55 +163,26 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\EditAction::make(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\EditAction::make()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('check_connection')
|
||||
->label('Check connection')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -252,55 +240,26 @@ public static function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('inventory_sync')
|
||||
->label('Inventory sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -358,55 +317,26 @@ public static function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('compliance_snapshot')
|
||||
->label('Compliance snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(function (ProviderConnection $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -464,19 +394,24 @@ public static function table(Table $table): Table
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('set_default')
|
||||
->label('Set as default')
|
||||
->icon('heroicon-o-star')
|
||||
->color('primary')
|
||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||
&& $record->status !== 'disabled'
|
||||
&& ! $record->is_default)
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->makeDefault();
|
||||
|
||||
@ -506,14 +441,18 @@ public static function table(Table $table): Table
|
||||
->title('Default connection updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
@ -528,7 +467,9 @@ public static function table(Table $table): Table
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
connection: $record,
|
||||
@ -562,18 +503,23 @@ public static function table(Table $table): Table
|
||||
->title('Credentials updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('enable_connection')
|
||||
->label('Enable connection')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||
&& $record->status === 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
@ -626,19 +572,25 @@ public static function table(Table $table): Table
|
||||
->title('Provider connection enabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('disable_connection')
|
||||
->label('Disable connection')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||
&& $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
@ -673,7 +625,11 @@ public static function table(Table $table): Table
|
||||
->title('Provider connection disabled')
|
||||
->warning()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -10,18 +10,19 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class EditProviderConnection extends EditRecord
|
||||
{
|
||||
@ -115,12 +116,12 @@ protected function getHeaderActions(): array
|
||||
->visible(false),
|
||||
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Action::make('view_last_check_run')
|
||||
->label('View last check run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
|
||||
&& OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
@ -145,8 +146,14 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($run, $tenant);
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||
->tooltip('You do not have permission to view provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('check_connection')
|
||||
->label('Check connection')
|
||||
->icon('heroicon-o-check-badge')
|
||||
@ -160,36 +167,22 @@ protected function getHeaderActions(): array
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -247,14 +240,20 @@ protected function getHeaderActions(): array
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('update_credentials')
|
||||
->label('Update credentials')
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant))
|
||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||
->form([
|
||||
TextInput::make('client_id')
|
||||
->label('Client ID')
|
||||
@ -269,7 +268,9 @@ protected function getHeaderActions(): array
|
||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$credentials->upsertClientSecretCredential(
|
||||
connection: $record,
|
||||
@ -303,14 +304,19 @@ protected function getHeaderActions(): array
|
||||
->title('Credentials updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('set_default')
|
||||
->label('Set as default')
|
||||
->icon('heroicon-o-star')
|
||||
->color('primary')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||
&& $record->status !== 'disabled'
|
||||
&& ! $record->is_default
|
||||
&& ProviderConnection::query()
|
||||
@ -320,7 +326,9 @@ protected function getHeaderActions(): array
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$record->makeDefault();
|
||||
|
||||
@ -350,8 +358,14 @@ protected function getHeaderActions(): array
|
||||
->title('Default connection updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('inventory_sync')
|
||||
->label('Inventory sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -365,36 +379,22 @@ protected function getHeaderActions(): array
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -452,8 +452,14 @@ protected function getHeaderActions(): array
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('compliance_snapshot')
|
||||
->label('Compliance snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
@ -467,36 +473,22 @@ protected function getHeaderActions(): array
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
->disabled(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||
})
|
||||
->tooltip(function (): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||
? null
|
||||
: 'You do not have permission to run provider operations.';
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($tenant), 404);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$initiator = $user;
|
||||
|
||||
$result = $gate->start(
|
||||
@ -554,19 +546,25 @@ protected function getHeaderActions(): array
|
||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('enable_connection')
|
||||
->label('Enable connection')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||
&& $record->status === 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
@ -619,20 +617,26 @@ protected function getHeaderActions(): array
|
||||
->title('Provider connection enabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('disable_connection')
|
||||
->label('Disable connection')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||
&& $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
@ -667,7 +671,12 @@ protected function getHeaderActions(): array
|
||||
->title('Provider connection disabled')
|
||||
->warning()
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to manage provider connections.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
@ -679,7 +688,17 @@ protected function getFormActions(): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return [
|
||||
$this->getCancelFormAction(),
|
||||
];
|
||||
}
|
||||
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||
return parent::getFormActions();
|
||||
}
|
||||
|
||||
@ -692,7 +711,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -13,11 +15,13 @@ class ListProviderConnections extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\CreateAction::make()
|
||||
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
|
||||
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())
|
||||
? null
|
||||
: 'You do not have permission to create provider connections.'),
|
||||
->authorize(fn (): bool => true)
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->tooltip('You do not have permission to create provider connections.')
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreDiffGenerator;
|
||||
@ -27,6 +28,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use BackedEnum;
|
||||
@ -50,7 +52,6 @@
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
@ -65,8 +66,18 @@ class RestoreRunResource extends Resource
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return ($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -748,6 +759,7 @@ public static function table(Table $table): Table
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('rerun')
|
||||
->label('Rerun')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -761,18 +773,12 @@ public static function table(Table $table): Table
|
||||
&& $backupSet !== null
|
||||
&& ! $backupSet->trashed();
|
||||
})
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (
|
||||
RestoreRun $record,
|
||||
RestoreService $restoreService,
|
||||
\App\Services\Intune\AuditLogger $auditLogger,
|
||||
HasTable $livewire
|
||||
) {
|
||||
$currentTenant = Tenant::current();
|
||||
|
||||
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
|
||||
|
||||
$tenant = $record->tenant;
|
||||
$backupSet = $record->backupSet;
|
||||
|
||||
@ -933,19 +939,19 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('restore.execute')
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant) {
|
||||
@ -964,19 +970,19 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
if (! $record->isDeletable()) {
|
||||
Notification::make()
|
||||
->title('Restore run cannot be archived')
|
||||
@ -1005,19 +1011,19 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||
|
||||
if ($record->tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
@ -1036,17 +1042,21 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Restore Runs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -1080,8 +1090,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -1122,14 +1130,16 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Restore Runs')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -1150,8 +1160,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -1203,14 +1211,16 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Restore Runs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -1240,8 +1250,6 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -1293,6 +1301,9 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@ -1491,10 +1502,23 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
|
||||
public static function createRestoreRun(array $data): RestoreRun
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BackupSet $backupSet */
|
||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||
|
||||
@ -5,12 +5,13 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
@ -23,7 +24,21 @@ protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSteps(): array
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
@ -43,7 +44,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
@ -79,7 +79,11 @@ public static function canEdit(Model $record): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
@ -90,7 +94,11 @@ public static function canDelete(Model $record): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $record instanceof Tenant
|
||||
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
@ -106,36 +114,16 @@ public static function canDeleteAny(): bool
|
||||
|
||||
private static function userCanManageAnyTenant(User $user): bool
|
||||
{
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||
}
|
||||
|
||||
private static function userCanDeleteAnyTenant(User $user): bool
|
||||
{
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -299,7 +287,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
@ -308,15 +299,30 @@ public static function table(Table $table): Table
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record)
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
|
||||
? null
|
||||
: 'You do not have permission to sync this tenant.';
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->user();
|
||||
abort_unless($user instanceof User, 403);
|
||||
abort_unless($user->canAccessTenant($record), 404);
|
||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record), 403);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -416,7 +422,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
@ -425,7 +434,10 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -452,7 +464,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
@ -461,7 +476,10 @@ public static function table(Table $table): Table
|
||||
return null;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_MANAGE)
|
||||
? null
|
||||
: 'You do not have permission to manage tenant consent.';
|
||||
})
|
||||
@ -485,7 +503,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
@ -500,7 +521,10 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -520,7 +544,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
@ -529,7 +556,10 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -567,7 +597,10 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||
if ($record === null) {
|
||||
@ -580,7 +613,10 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@ -648,9 +684,12 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$eligible = $records
|
||||
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
||||
->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
||||
->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
|
||||
if ($eligible->isEmpty()) {
|
||||
Notification::make()
|
||||
@ -893,7 +932,10 @@ public static function rbacAction(): Actions\Action
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->action(function (
|
||||
@ -908,7 +950,10 @@ public static function rbacAction(): Actions\Action
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class EditTenant extends EditRecord
|
||||
{
|
||||
@ -18,42 +18,21 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\Action::make('archive')
|
||||
UiEnforcement::forAction(
|
||||
Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
||||
->disabled(function (): bool {
|
||||
$tenant = $this->record;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record): void {
|
||||
$record->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();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->tooltip('You do not have permission to archive tenants.')
|
||||
->preserveVisibility()
|
||||
->destructive()
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,14 +7,14 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Actions;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class TenantMembershipsRelationManager extends RelationManager
|
||||
{
|
||||
@ -40,18 +40,10 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->headerActions([
|
||||
Actions\Action::make('add_member')
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('add_member')
|
||||
->label(__('Add member'))
|
||||
->icon('heroicon-o-plus')
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||
})
|
||||
->form([
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label(__('User'))
|
||||
@ -80,10 +72,6 @@ public function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$member = User::query()->find((int) $data['user_id']);
|
||||
if (! $member) {
|
||||
Notification::make()->title(__('User not found'))->danger()->send();
|
||||
@ -112,21 +100,18 @@ public function table(Table $table): Table
|
||||
Notification::make()->title(__('Member added'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->apply(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('change_role')
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('change_role')
|
||||
->label(__('Change role'))
|
||||
->icon('heroicon-o-pencil')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||
})
|
||||
->form([
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
@ -150,10 +135,6 @@ public function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->changeRole(
|
||||
tenant: $tenant,
|
||||
@ -174,20 +155,18 @@ public function table(Table $table): Table
|
||||
Notification::make()->title(__('Role updated'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
Actions\Action::make('remove')
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('remove')
|
||||
->label(__('Remove'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||
})
|
||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
@ -200,10 +179,6 @@ public function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->removeMember($tenant, $actor, $record);
|
||||
} catch (\Throwable $throwable) {
|
||||
@ -219,6 +194,12 @@ public function table(Table $table): Table
|
||||
Notification::make()->title(__('Member removed'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class FindingPolicy
|
||||
{
|
||||
@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,8 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
@ -40,6 +42,8 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
|
||||
@ -58,6 +62,8 @@ class RoleCapabilityMap
|
||||
TenantRole::Operator->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
|
||||
@ -24,6 +24,12 @@ class Capabilities
|
||||
|
||||
public const TENANT_SYNC = 'tenant.sync';
|
||||
|
||||
// Inventory
|
||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||
|
||||
// Findings
|
||||
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||
|
||||
// Tenant memberships
|
||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||
|
||||
|
||||
48
app/Support/Rbac/TenantAccessContext.php
Normal file
48
app/Support/Rbac/TenantAccessContext.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* DTO representing the access context for a tenant-scoped UI action.
|
||||
*
|
||||
* Captures the current user, tenant, membership status, and capability check result
|
||||
* for use by the UiEnforcement helper.
|
||||
*/
|
||||
final readonly class TenantAccessContext
|
||||
{
|
||||
public function __construct(
|
||||
public ?User $user,
|
||||
public ?Tenant $tenant,
|
||||
public bool $isMember,
|
||||
public bool $hasCapability,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Non-members should receive 404 (deny-as-not-found).
|
||||
*/
|
||||
public function shouldDenyAsNotFound(): bool
|
||||
{
|
||||
return ! $this->isMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Members without capability should receive 403 (forbidden).
|
||||
*/
|
||||
public function shouldDenyAsForbidden(): bool
|
||||
{
|
||||
return $this->isMember && ! $this->hasCapability;
|
||||
}
|
||||
|
||||
/**
|
||||
* User is authorized to perform the action.
|
||||
*/
|
||||
public function isAuthorized(): bool
|
||||
{
|
||||
return $this->isMember && $this->hasCapability;
|
||||
}
|
||||
}
|
||||
414
app/Support/Rbac/UiEnforcement.php
Normal file
414
app/Support/Rbac/UiEnforcement.php
Normal file
@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use ReflectionObject;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Central RBAC UI Enforcement Helper for Filament Actions.
|
||||
*
|
||||
* Enforces constitution RBAC-UX rules:
|
||||
* - Non-member → hidden UI + 404 server-side
|
||||
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||
* - Member with capability → enabled
|
||||
* - Destructive actions → requiresConfirmation()
|
||||
*
|
||||
* @see \App\Support\Rbac\UiTooltips
|
||||
* @see \App\Support\Rbac\TenantAccessContext
|
||||
*/
|
||||
final class UiEnforcement
|
||||
{
|
||||
private Action|BulkAction $action;
|
||||
|
||||
private bool $requireMembership = true;
|
||||
|
||||
private ?string $capability = null;
|
||||
|
||||
private bool $isDestructive = false;
|
||||
|
||||
private ?string $customTooltip = null;
|
||||
|
||||
private Model|Closure|null $record = null;
|
||||
|
||||
private ?Collection $records = null;
|
||||
|
||||
private bool $isBulk = false;
|
||||
|
||||
private bool $preserveExistingVisibility = false;
|
||||
|
||||
private function __construct(Action|BulkAction $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a header/page action.
|
||||
*
|
||||
* @param Action $action The Filament action to wrap
|
||||
*/
|
||||
public static function forAction(Action $action): self
|
||||
{
|
||||
return new self($action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a table row action.
|
||||
*
|
||||
* @param Action $action The Filament action to wrap
|
||||
* @param Model|Closure $record The record or a closure that returns the record
|
||||
*/
|
||||
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->record = $record;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a bulk action with all-or-nothing semantics.
|
||||
*
|
||||
* If any selected record fails the capability check for a member,
|
||||
* the action is disabled entirely.
|
||||
*
|
||||
* @param BulkAction $action The Filament bulk action to wrap
|
||||
*/
|
||||
public static function forBulkAction(BulkAction $action): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->isBulk = true;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require tenant membership for this action.
|
||||
*
|
||||
* @param bool $require Whether membership is required (default: true)
|
||||
*/
|
||||
public function requireMembership(bool $require = true): self
|
||||
{
|
||||
$this->requireMembership = $require;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific capability for this action.
|
||||
*
|
||||
* @param string $capability A capability constant from Capabilities class
|
||||
*
|
||||
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||
*/
|
||||
public function requireCapability(string $capability): self
|
||||
{
|
||||
if (! Capabilities::isKnown($capability)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||
);
|
||||
}
|
||||
|
||||
$this->capability = $capability;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this action as destructive (requires confirmation modal).
|
||||
*/
|
||||
public function destructive(): self
|
||||
{
|
||||
$this->isDestructive = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default tooltip for disabled actions.
|
||||
*
|
||||
* @param string $message Custom tooltip message
|
||||
*/
|
||||
public function tooltip(string $message): self
|
||||
{
|
||||
$this->customTooltip = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the action's existing visibility logic.
|
||||
*
|
||||
* Use this when the action already has business-logic visibility
|
||||
* (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept.
|
||||
*
|
||||
* UiEnforcement will combine the existing visibility condition with tenant
|
||||
* membership visibility, instead of overwriting it.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function preserveVisibility(): self
|
||||
{
|
||||
$this->preserveExistingVisibility = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all enforcement rules to the action and return it.
|
||||
*
|
||||
* This sets up:
|
||||
* - UI visibility (hidden for non-members)
|
||||
* - UI disabled state + tooltip (for members without capability)
|
||||
* - Destructive confirmation (if marked)
|
||||
* - Server-side guards (404/403)
|
||||
*
|
||||
* @return Action|BulkAction The configured action
|
||||
*/
|
||||
public function apply(): Action|BulkAction
|
||||
{
|
||||
$this->applyVisibility();
|
||||
$this->applyDisabledState();
|
||||
$this->applyDestructiveConfirmation();
|
||||
$this->applyServerSideGuard();
|
||||
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide action for non-members.
|
||||
*
|
||||
* Skipped if preserveVisibility() was called.
|
||||
*/
|
||||
private function applyVisibility(): void
|
||||
{
|
||||
if (! $this->requireMembership) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingVisibility = $this->preserveExistingVisibility
|
||||
? $this->getExistingVisibilityCondition()
|
||||
: null;
|
||||
|
||||
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if (! $context->isMember) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($existingVisibility === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to retrieve the existing visibility condition from the action.
|
||||
*
|
||||
* Filament stores this as the protected property `$isVisible` (bool|Closure)
|
||||
* on actions via the CanBeHidden concern.
|
||||
*/
|
||||
private function getExistingVisibilityCondition(): bool|Closure|null
|
||||
{
|
||||
try {
|
||||
$ref = new ReflectionObject($this->action);
|
||||
if (! $ref->hasProperty('isVisible')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$property = $ref->getProperty('isVisible');
|
||||
$property->setAccessible(true);
|
||||
|
||||
/** @var bool|Closure $value */
|
||||
$value = $property->getValue($this->action);
|
||||
|
||||
return $value;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an existing bool|Closure visibility condition.
|
||||
*
|
||||
* This is a best-effort evaluator for business visibility closures.
|
||||
* If the closure cannot be evaluated safely, we fail closed (return false).
|
||||
*/
|
||||
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
||||
{
|
||||
if (is_bool($condition)) {
|
||||
return $condition;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionFunction($condition);
|
||||
$parameters = $reflection->getParameters();
|
||||
|
||||
if ($parameters === []) {
|
||||
return (bool) $condition();
|
||||
}
|
||||
|
||||
if ($record === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $condition($record);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable action for members without capability.
|
||||
*/
|
||||
private function applyDisabledState(): void
|
||||
{
|
||||
if ($this->capability === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
|
||||
$this->action->disabled(function (?Model $record = null) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
// Non-members are hidden, so this only affects members
|
||||
if (! $context->isMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $context->hasCapability;
|
||||
});
|
||||
|
||||
// Only show tooltip when actually disabled
|
||||
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
return $tooltip;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add confirmation modal for destructive actions.
|
||||
*/
|
||||
private function applyDestructiveConfirmation(): void
|
||||
{
|
||||
if (! $this->isDestructive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->requiresConfirmation();
|
||||
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the action handler with server-side authorization guard.
|
||||
*
|
||||
* This is a defense-in-depth measure. In normal operation, Filament's
|
||||
* isDisabled() check prevents execution. This guard catches edge cases
|
||||
* where the disabled check might be bypassed.
|
||||
*/
|
||||
private function applyServerSideGuard(): void
|
||||
{
|
||||
$this->action->before(function (?Model $record = null): void {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
// Non-member → 404 (deny-as-not-found)
|
||||
if ($context->shouldDenyAsNotFound()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Member without capability → 403 (forbidden)
|
||||
if ($context->shouldDenyAsForbidden()) {
|
||||
abort(403);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current access context with an optional record.
|
||||
*/
|
||||
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
||||
$tenant = $this->resolveTenantWithRecord($record);
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return new TenantAccessContext(
|
||||
user: null,
|
||||
tenant: null,
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$isMember = $resolver->isMember($user, $tenant);
|
||||
|
||||
$hasCapability = true;
|
||||
if ($this->capability !== null && $isMember) {
|
||||
$hasCapability = $resolver->can($user, $tenant, $this->capability);
|
||||
}
|
||||
|
||||
return new TenantAccessContext(
|
||||
user: $user,
|
||||
tenant: $tenant,
|
||||
isMember: $isMember,
|
||||
hasCapability: $hasCapability,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tenant for this action with an optional record.
|
||||
*
|
||||
* Priority:
|
||||
* 1. If $record is passed and is a Tenant, use it
|
||||
* 2. If $this->record is set (for forTableAction), resolve it
|
||||
* 3. Fall back to Filament::getTenant()
|
||||
*/
|
||||
private function resolveTenantWithRecord(?Model $record = null): ?Tenant
|
||||
{
|
||||
// If a record is passed directly (from closure parameter), check if it's a Tenant
|
||||
if ($record instanceof Tenant) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
// If a record is set from forTableAction, try to resolve it
|
||||
if ($this->record !== null) {
|
||||
$resolved = $this->record instanceof Closure
|
||||
? ($this->record)()
|
||||
: $this->record;
|
||||
|
||||
if ($resolved instanceof Tenant) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: use Filament's current tenant
|
||||
return Filament::getTenant();
|
||||
}
|
||||
}
|
||||
33
app/Support/Rbac/UiTooltips.php
Normal file
33
app/Support/Rbac/UiTooltips.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac;
|
||||
|
||||
/**
|
||||
* Standardized tooltip and confirmation messages for RBAC UI enforcement.
|
||||
*
|
||||
* These constants provide consistent, non-leaky messaging for:
|
||||
* - Permission denials (members lacking capability)
|
||||
* - Destructive action confirmations
|
||||
*
|
||||
* @see \App\Support\Rbac\UiEnforcement
|
||||
*/
|
||||
final class UiTooltips
|
||||
{
|
||||
/**
|
||||
* Tooltip shown when a member lacks the required capability.
|
||||
* Intentionally vague to avoid leaking permission structure.
|
||||
*/
|
||||
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
|
||||
|
||||
/**
|
||||
* Modal heading for destructive action confirmation.
|
||||
*/
|
||||
public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?';
|
||||
|
||||
/**
|
||||
* Modal description for destructive action confirmation.
|
||||
*/
|
||||
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user