066-rbac-ui-enforcement-helper #81

Merged
ahmido merged 6 commits from 066-rbac-ui-enforcement-helper into dev 2026-01-30 16:58:03 +00:00
31 changed files with 4955 additions and 4441 deletions
Showing only changes of commit 538c6ea268 - Show all commits

4
.gitignore vendored
View File

@ -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

View File

@ -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.';

View File

@ -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);

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
@ -19,11 +20,13 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -34,7 +37,6 @@
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class BackupSetResource extends Resource
@ -47,8 +49,18 @@ class BackupSetResource extends Resource
public static function canCreate(): bool
{
return ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant);
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public static function form(Schema $schema): Schema
@ -90,353 +102,356 @@ public static function table(Table $table): Table
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
ActionGroup::make([
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->restore();
$record->items()->withTrashed()->restore();
$record->restore();
$record->items()->withTrashed()->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
Notification::make()
->title('Backup set restored')
->success()
->send();
}),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
Notification::make()
->title('Backup set archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->title('Backup set restored')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
return;
}
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set archived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
}),
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->send();
return;
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['name' => $record->name]]
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return $isOnlyTrashed;
})
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $tenant instanceof Tenant) {
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
$initiator = $user instanceof User ? $user : null;
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
$isOnlyTrashed = in_array($value, [0, '0', false], true);
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 10) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
if (! $tenant instanceof Tenant) {
return;
}
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$initiator = $user instanceof User ? $user : null;
if (! $tenant instanceof Tenant) {
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = $user instanceof User ? $user : null;
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
]);
}

View File

@ -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,
]),
]);
}

View File

@ -12,10 +12,10 @@
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroups extends ListRecords
{
@ -29,121 +29,90 @@ protected function getHeaderActions(): array
->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()),
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
return false;
}
$selectionKey = EntraGroupSelection::allGroupsV1();
$tenant = Tenant::current();
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
);
if (! $tenant) {
return false;
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
if (! $user->canAccessTenant($tenant)) {
return false;
}
return;
}
// ----------------------------------------------
return true;
})
->disabled(function (): bool {
$user = auth()->user();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if (! $user instanceof User) {
return true;
}
if ($existing instanceof EntraGroupSyncRun) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
$tenant = Tenant::current();
return;
}
if (! $tenant instanceof Tenant) {
return true;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
operationRun: $opRun
));
if (! $user instanceof User) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync groups.';
})
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::current();
if (! $tenant) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
$selectionKey = EntraGroupSelection::allGroupsV1();
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([
Action::make('view_run')
->label('View Run')
@ -151,38 +120,11 @@ protected function getHeaderActions(): array
])
->sendToDatabase($user)
->send();
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
operationRun: $opRun
));
Notification::make()
->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync groups.')
->apply(),
];
}
}

View File

@ -10,9 +10,10 @@
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroupSyncRuns extends ListRecords
{
@ -21,92 +22,67 @@ class ListEntraGroupSyncRuns extends ListRecords
protected function getHeaderActions(): array
{
return [
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$user = auth()->user();
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
if (! $user instanceof User) {
return false;
}
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
$tenant = Tenant::current();
$selectionKey = EntraGroupSelection::allGroupsV1();
if (! $tenant) {
return false;
}
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if (! $user->canAccessTenant($tenant)) {
return false;
}
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->action(function (): void {
$user = auth()->user();
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
]));
if (! $user instanceof User) {
abort(403);
}
return;
}
$tenant = Tenant::current();
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
if (! $tenant) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -12,10 +12,13 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
@ -29,7 +32,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class FindingResource extends Resource
@ -46,19 +48,34 @@ public static function canViewAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::TENANT_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
if (! $user->canAccessTenant($tenant)) {
return false;
}
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
return false;
}
@ -343,75 +360,62 @@ public static function table(Table $table): Table
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('acknowledge_selected')
->label('Acknowledge selected')
->icon('heroicon-o-check')
->color('gray')
->authorize(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
UiEnforcement::forBulkAction(
BulkAction::make('acknowledge_selected')
->label('Acknowledge selected')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->action(function (Collection $records): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant || ! $user instanceof User) {
return false;
}
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
return $user->can('update', $probe);
})
->authorizeIndividualRecords('update')
->requiresConfirmation()
->action(function (Collection $records): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || ! $user instanceof User) {
return;
}
$firstRecord = $records->first();
if ($firstRecord instanceof Finding) {
Gate::authorize('update', $firstRecord);
}
$acknowledgedCount = 0;
$skippedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
$acknowledgedCount = 0;
$skippedCount = 0;
continue;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if ($record->status !== Finding::STATUS_NEW) {
$skippedCount++;
continue;
}
$record->acknowledge($user);
$acknowledgedCount++;
}
if ($record->status !== Finding::STATUS_NEW) {
$skippedCount++;
continue;
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
$record->acknowledge($user);
$acknowledgedCount++;
}
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
Notification::make()
->title('Bulk acknowledge completed')
->body($body)
->success()
->send();
})
->deselectRecordsAfterCompletion(),
Notification::make()
->title('Bulk acknowledge completed')
->body($body)
->success()
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
]),
]);
}

View File

@ -4,15 +4,15 @@
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
class ListFindings extends ListRecords
{
@ -21,101 +21,83 @@ class ListFindings extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\Action::make('acknowledge_all_matching')
->label('Acknowledge all matching')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->authorize(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
UiEnforcement::forAction(
Actions\Action::make('acknowledge_all_matching')
->label('Acknowledge all matching')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->modalDescription(function (): string {
$count = $this->getAllMatchingCount();
if (! $tenant || ! $user instanceof User) {
return false;
}
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
})
->form(function (): array {
$count = $this->getAllMatchingCount();
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
if ($count <= 100) {
return [];
}
return $user->can('update', $probe);
})
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->modalDescription(function (): string {
$count = $this->getAllMatchingCount();
return [
TextInput::make('confirmation')
->label('Type ACKNOWLEDGE to confirm')
->required()
->in(['ACKNOWLEDGE'])
->validationMessages([
'in' => 'Please type ACKNOWLEDGE to confirm.',
]),
];
})
->action(function (array $data): void {
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
})
->form(function (): array {
$count = $this->getAllMatchingCount();
if ($count === 0) {
Notification::make()
->title('No matching findings')
->body('There are no new findings matching the current filters.')
->warning()
->send();
if ($count <= 100) {
return [];
}
return;
}
return [
TextInput::make('confirmation')
->label('Type ACKNOWLEDGE to confirm')
->required()
->in(['ACKNOWLEDGE'])
->validationMessages([
'in' => 'Please type ACKNOWLEDGE to confirm.',
]),
];
})
->action(function (array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
$updated = $query->update([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => auth()->id(),
]);
if (! $tenant || ! $user instanceof User) {
return;
}
$this->deselectAllTableRecords();
$this->resetPage();
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
if ($count === 0) {
Notification::make()
->title('No matching findings')
->body('There are no new findings matching the current filters.')
->warning()
->title('Bulk acknowledge completed')
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
->success()
->send();
return;
}
$firstRecord = (clone $query)->first();
if ($firstRecord instanceof Finding) {
Gate::authorize('update', $firstRecord);
}
$updated = $query->update([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$this->deselectAllTableRecords();
$this->resetPage();
Notification::make()
->title('Bulk acknowledge completed')
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
protected function buildAllMatchingQuery(): Builder
{
$tenant = Tenant::current();
$query = Finding::query();
if (! $tenant) {
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
if (! is_numeric($tenantId)) {
return $query->whereRaw('1 = 0');
}
$query->where('tenant_id', $tenant->getKey());
$query->where('tenant_id', (int) $tenantId);
$query->where('status', Finding::STATUS_NEW);

View File

@ -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;
}

View File

@ -16,6 +16,8 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
use Filament\Forms\Components\Hidden;
@ -24,7 +26,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Size;
use Illuminate\Support\Facades\Gate;
class ListInventoryItems extends ListRecords
{
@ -40,244 +41,211 @@ protected function getHeaderWidgets(): array
protected function getHeaderActions(): array
{
return [
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Select::make('policy_types')
->label('Policy types')
->multiple()
->searchable()
->preload()
->native(false)
->hintActions([
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
->label('Select all')
->link()
->size(Size::Small)
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
}),
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
->label('Clear')
->link()
->size(Size::Small)
->action(function () use ($component): void {
$component->state([]);
}),
])
->options(function (): array {
return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
->mapWithKeys(function ($items, string $category): array {
$options = collect($items)
->mapWithKeys(function (array $meta): array {
$type = (string) $meta['type'];
$label = (string) ($meta['label'] ?? $type);
$platform = (string) ($meta['platform'] ?? 'all');
UiEnforcement::forAction(
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Select::make('policy_types')
->label('Policy types')
->multiple()
->searchable()
->preload()
->native(false)
->hintActions([
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
->label('Select all')
->link()
->size(Size::Small)
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
}),
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
->label('Clear')
->link()
->size(Size::Small)
->action(function () use ($component): void {
$component->state([]);
}),
])
->options(function (): array {
return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
->mapWithKeys(function ($items, string $category): array {
$options = collect($items)
->mapWithKeys(function (array $meta): array {
$type = (string) $meta['type'];
$label = (string) ($meta['label'] ?? $type);
$platform = (string) ($meta['platform'] ?? 'all');
return [$type => "{$label}{$platform}"];
})
->all();
return [$type => "{$label}{$platform}"];
})
->all();
return [$category => $options];
})
->all();
})
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->helperText('Include scope tags, assignment filters, and notification templates.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Toggle::make('include_dependencies')
->label('Include dependencies')
->helperText('Include dependency extraction where supported.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(),
])
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return [$category => $options];
})
->all();
})
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->helperText('Include scope tags, assignment filters, and notification templates.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Toggle::make('include_dependencies')
->label('Include dependencies')
->helperText('Include dependency extraction where supported.')
->default(true)
->dehydrated()
->rules(['boolean'])
->columnSpanFull(),
Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey())
->dehydrated(),
])
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
return $user->canAccessTenant($tenant);
})
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
$user = auth()->user();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$requestedTenantId = $data['tenant_id'] ?? null;
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
Notification::make()
->title('Not allowed')
->danger()
->send();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to start inventory sync.';
})
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
if (array_key_exists('policy_types', $data)) {
$selectionPayload['policy_types'] = $data['policy_types'];
}
if (array_key_exists('include_foundations', $data)) {
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
}
if (array_key_exists('include_dependencies', $data)) {
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'inventory.sync',
inputs: $computed['selection'],
initiator: $user
);
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403, 'Not allowed');
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$requestedTenantId = $data['tenant_id'] ?? null;
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
Notification::make()
->title('Not allowed')
->danger()
->send();
return;
}
abort(403, 'Not allowed');
}
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
if (array_key_exists('policy_types', $data)) {
$selectionPayload['policy_types'] = $data['policy_types'];
}
if (array_key_exists('include_foundations', $data)) {
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
}
if (array_key_exists('include_dependencies', $data)) {
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Inventory sync already active')
->body('A matching inventory sync run is already pending or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'inventory.sync',
inputs: $computed['selection'],
initiator: $user
);
return;
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
operationRun: $opRun
);
});
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View Run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
return;
}
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Inventory sync already active')
->body('A matching inventory sync run is already pending or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
operationRun: $opRun
);
});
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,10 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListPolicies extends ListRecords
{
@ -23,109 +23,70 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (): bool {
$user = auth()->user();
UiEnforcement::forAction(
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$tenant = Tenant::current();
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
return $tenant instanceof Tenant
&& $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
$tenant = Tenant::current();
sort($requestedTypes);
return ! ($user instanceof User
&& $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
})
->tooltip(function (): ?string {
$user = auth()->user();
$tenant = Tenant::current();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! ($user instanceof User && $tenant instanceof Tenant)) {
return null;
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync policies.';
})
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
return;
}
if (! $user instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->destructive()
->apply(),
];
}
}

View File

@ -5,17 +5,20 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Gate;
class VersionsRelationManager extends RelationManager
{
@ -23,6 +26,116 @@ class VersionsRelationManager extends RelationManager
public function table(Table $table): Table
{
$restoreToIntune = Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()
->title('Missing tenant or user context.')
->danger()
->send();
return;
}
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: $user->email,
actorName: $user->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
});
UiEnforcement::forAction($restoreToIntune)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
$restoreToIntune
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true;
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
})
->tooltip(function (PolicyVersion $record): ?string {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return UiTooltips::INSUFFICIENT_PERMISSION;
}
return null;
});
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(),
@ -38,61 +151,7 @@ public function table(Table $table): Table
->filters([])
->headerActions([])
->actions([
Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
}),
$restoreToIntune,
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -13,11 +15,13 @@ class ListProviderConnections extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())
? null
: 'You do not have permission to create provider connections.'),
UiEnforcement::forAction(
Actions\CreateAction::make()
->authorize(fn (): bool => true)
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to create provider connections.')
->apply(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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);
}

View File

@ -4,11 +4,11 @@
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Gate;
class EditTenant extends EditRecord
{
@ -18,42 +18,21 @@ protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->requiresConfirmation()
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
->disabled(function (): bool {
$tenant = $this->record;
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
})
->tooltip(function (): ?string {
$tenant = $this->record;
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)
? null
: 'You do not have permission to archive tenants.';
})
->action(function (): void {
$tenant = $this->record;
$user = auth()->user();
abort_unless($tenant instanceof Tenant && $user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant), 403);
$tenant->delete();
}),
UiEnforcement::forAction(
Action::make('archive')
->label('Archive')
->color('danger')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record): void {
$record->delete();
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to archive tenants.')
->preserveVisibility()
->destructive()
->apply(),
];
}
}

View File

@ -7,14 +7,14 @@
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Auth\Capabilities;
use Filament\Actions;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
class TenantMembershipsRelationManager extends RelationManager
{
@ -40,185 +40,166 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
Actions\Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
UiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
if (! $tenant instanceof Tenant) {
abort(404);
}
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
abort(404);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
return;
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
try {
$manager->addMember(
tenant: $tenant,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
return;
}
try {
$manager->addMember(
tenant: $tenant,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
])
->actions([
Actions\Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
if (! $tenant instanceof Tenant) {
abort(404);
}
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('Readonly'),
]),
])
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
abort(404);
}
try {
$manager->changeRole(
tenant: $tenant,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
return;
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
try {
$manager->changeRole(
tenant: $tenant,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
return;
}
if (! $tenant instanceof Tenant) {
abort(404);
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
Actions\Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
return false;
}
try {
$manager->removeMember($tenant, $actor, $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
return;
}
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
try {
$manager->removeMember($tenant, $actor, $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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';

View 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;
}
}

View 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();
}
}

View 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.';
}