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
63 changed files with 7105 additions and 4466 deletions

4
.gitignore vendored
View File

@ -1,6 +1,7 @@
*.log *.log
.DS_Store .DS_Store
.env .env
.env.*
.env.backup .env.backup
.env.production .env.production
.phpactor.json .phpactor.json
@ -21,7 +22,10 @@ coverage/
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/framework
/storage/logs
/vendor /vendor
/bootstrap/cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db

View File

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

View File

@ -16,6 +16,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
@ -24,7 +25,6 @@
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class BackupItemsRelationManager extends RelationManager class BackupItemsRelationManager extends RelationManager
{ {
@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void
public function table(Table $table): Table 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 return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([ ->columns([
@ -125,29 +318,8 @@ public function table(Table $table): Table
]) ])
->filters([]) ->filters([])
->headerActions([ ->headerActions([
Actions\Action::make('refreshTable') $refreshTable,
->label('Refresh') $addPolicies,
->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(),
]);
}),
]) ])
->actions([ ->actions([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
@ -164,174 +336,12 @@ public function table(Table $table): Table
}) })
->hidden(fn (BackupItem $record) => ! $record->policy_id) ->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true), ->openUrlInNewTab(true),
Actions\Action::make('remove') $removeItem,
->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();
}),
])->icon('heroicon-o-ellipsis-vertical'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([ ->bulkActions([
Actions\BulkActionGroup::make([ Actions\BulkActionGroup::make([
Actions\BulkAction::make('bulk_remove') $bulkRemove,
->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();
}),
]), ]),
]); ]);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@
use App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -26,7 +28,6 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class InventoryItemResource extends Resource class InventoryItemResource extends Resource
@ -44,20 +45,34 @@ class InventoryItemResource extends Resource
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant if (! $tenant instanceof Tenant || ! $user instanceof User) {
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant); 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 public static function canView(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false; 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; return false;
} }

View File

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

View File

@ -6,6 +6,8 @@
use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
@ -21,7 +23,6 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class InventorySyncRunResource extends Resource class InventorySyncRunResource extends Resource
@ -41,20 +42,31 @@ class InventorySyncRunResource extends Resource
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant if (! $tenant instanceof Tenant || ! $user instanceof User) {
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant); return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
} }
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false; 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; 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\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListPolicies extends ListRecords class ListPolicies extends ListRecords
{ {
@ -23,109 +23,70 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\Action::make('sync') UiEnforcement::forAction(
->label('Sync from Intune') Actions\Action::make('sync')
->icon('heroicon-o-arrow-path') ->label('Sync from Intune')
->color('primary') ->icon('heroicon-o-arrow-path')
->requiresConfirmation() ->color('primary')
->visible(function (): bool { ->action(function (self $livewire): void {
$user = auth()->user(); $tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User || ! $tenant instanceof Tenant) {
return false; 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 sort($requestedTypes);
&& $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
$tenant = Tenant::current();
return ! ($user instanceof User /** @var OperationRunService $opService */
&& $tenant instanceof Tenant $opService = app(OperationRunService::class);
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); $opRun = $opService->ensureRun(
}) tenant: $tenant,
->tooltip(function (): ?string { type: 'policy.sync',
$user = auth()->user(); inputs: [
$tenant = Tenant::current(); 'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! ($user instanceof User && $tenant instanceof Tenant)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return null; 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) return;
? null }
: 'You do not have permission to sync policies.';
})
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) { $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
abort(403); SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
} });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
if (! $tenant instanceof Tenant) { OperationUxPresenter::queuedToast((string) $opRun->type)
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()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
})
return; )
} ->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { ->destructive()
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); ->apply(),
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
]; ];
} }
} }

View File

@ -5,17 +5,20 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Gate;
class VersionsRelationManager extends RelationManager class VersionsRelationManager extends RelationManager
{ {
@ -23,6 +26,116 @@ class VersionsRelationManager extends RelationManager
public function table(Table $table): Table 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 return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(), Tables\Columns\TextColumn::make('version_number')->sortable(),
@ -38,61 +151,7 @@ public function table(Table $table): Table
->filters([]) ->filters([])
->headerActions([]) ->headerActions([])
->actions([ ->actions([
Actions\Action::make('restore_to_intune') $restoreToIntune,
->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]));
}),
Actions\ViewAction::make() Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false), ->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; namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -13,11 +15,13 @@ class ListProviderConnections extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() UiEnforcement::forAction(
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())) Actions\CreateAction::make()
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()) ->authorize(fn (): bool => true)
? null )
: 'You do not have permission to create provider connections.'), ->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\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\On; use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
@ -23,7 +24,21 @@ protected function authorizeAccess(): void
{ {
$tenant = Tenant::current(); $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 public function getSteps(): array

View File

@ -9,6 +9,7 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
@ -43,7 +44,6 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
@ -79,7 +79,11 @@ public static function canEdit(Model $record): bool
return false; 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 public static function canDelete(Model $record): bool
@ -90,7 +94,11 @@ public static function canDelete(Model $record): bool
return false; 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 public static function canDeleteAny(): bool
@ -106,36 +114,16 @@ public static function canDeleteAny(): bool
private static function userCanManageAnyTenant(User $user): bool private static function userCanManageAnyTenant(User $user): bool
{ {
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); return $user->tenantMemberships()
->pluck('role')
if ($tenantIds->isEmpty()) { ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
return false;
}
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
return true;
}
}
return false;
} }
private static function userCanDeleteAnyTenant(User $user): bool private static function userCanDeleteAnyTenant(User $user): bool
{ {
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); return $user->tenantMemberships()
->pluck('role')
if ($tenantIds->isEmpty()) { ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
return false;
}
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) {
return true;
}
}
return false;
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -299,7 +287,10 @@ public static function table(Table $table): Table
return true; 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 { ->tooltip(function (Tenant $record): ?string {
$user = auth()->user(); $user = auth()->user();
@ -308,15 +299,30 @@ public static function table(Table $table): Table
return null; 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 ? null
: 'You do not have permission to sync this tenant.'; : 'You do not have permission to sync this tenant.';
}) })
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
$user = auth()->user(); $user = auth()->user();
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($record), 404); if (! $user instanceof User) {
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record), 403); 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 */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -416,7 +422,10 @@ public static function table(Table $table): Table
return true; 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 { ->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user(); $user = auth()->user();
@ -425,7 +434,10 @@ public static function table(Table $table): Table
abort(403); 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); abort(403);
} }
@ -452,7 +464,10 @@ public static function table(Table $table): Table
return true; 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 { ->tooltip(function (Tenant $record): ?string {
$user = auth()->user(); $user = auth()->user();
@ -461,7 +476,10 @@ public static function table(Table $table): Table
return null; 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 ? null
: 'You do not have permission to manage tenant consent.'; : 'You do not have permission to manage tenant consent.';
}) })
@ -485,7 +503,10 @@ public static function table(Table $table): Table
return true; 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 ( ->action(function (
Tenant $record, Tenant $record,
@ -500,7 +521,10 @@ public static function table(Table $table): Table
abort(403); 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); abort(403);
} }
@ -520,7 +544,10 @@ public static function table(Table $table): Table
return true; 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) { ->action(function (Tenant $record, AuditLogger $auditLogger) {
$user = auth()->user(); $user = auth()->user();
@ -529,7 +556,10 @@ public static function table(Table $table): Table
abort(403); 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); abort(403);
} }
@ -567,7 +597,10 @@ public static function table(Table $table): Table
return true; 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) { ->action(function (?Tenant $record, AuditLogger $auditLogger) {
if ($record === null) { if ($record === null) {
@ -580,7 +613,10 @@ public static function table(Table $table): Table
abort(403); 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); abort(403);
} }
@ -648,9 +684,12 @@ public static function table(Table $table): Table
return; return;
} }
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$eligible = $records $eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) ->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()) { if ($eligible->isEmpty()) {
Notification::make() Notification::make()
@ -893,7 +932,10 @@ public static function rbacAction(): Actions\Action
return true; 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() ->requiresConfirmation()
->action(function ( ->action(function (
@ -908,7 +950,10 @@ public static function rbacAction(): Actions\Action
abort(403); 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); abort(403);
} }

View File

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

View File

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

View File

@ -5,9 +5,9 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
class FindingPolicy class FindingPolicy
{ {
@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool
return false; 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_MANAGE,
Capabilities::TENANT_DELETE, Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE, Capabilities::TENANT_MEMBERSHIP_MANAGE,
@ -40,6 +42,8 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE, Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,
@ -58,6 +62,8 @@ class RoleCapabilityMap
TenantRole::Operator->value => [ TenantRole::Operator->value => [
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW,

View File

@ -24,6 +24,12 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync'; 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 // Tenant memberships
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view'; 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.';
}

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: RBAC UI Enforcement Helper v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-28
**Feature**: [specs/066-rbac-ui-enforcement-helper/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- No blockers found in this iteration.

View File

@ -0,0 +1,188 @@
# Implementation Plan: RBAC UI Enforcement Helper v1
**Branch**: `066-rbac-ui-enforcement-helper` | **Date**: 2026-01-28 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/066-rbac-ui-enforcement-helper/spec.md`
## Summary
Provide a single, centrally maintained enforcement helper (`UiEnforcement`) that codifies the RBAC-UX constitution rules for tenant-scoped Filament actions:
- Non-member → 404 (deny-as-not-found), hidden in UI
- Member without capability → 403 on execution, visible-but-disabled in UI with standard tooltip
- Member with capability → enabled
- Destructive actions → `requiresConfirmation()` + clear warning
The helper wraps/augments Filament Actions (header, table row, bulk) to provide default UI + server-side enforcement, and ships with regression tests + a CI-failing guard against ad-hoc authorization patterns.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4
**Storage**: PostgreSQL (existing tables — no new tables)
**Testing**: Pest v4 (Feature + Unit tests)
**Target Platform**: Docker / Sail local, Dokploy VPS (Linux)
**Project Type**: Web / Monolith (backend + Filament admin)
**Performance Goals**: No additional DB queries beyond request-scope cached membership (FR-012)
**Constraints**: DB-only at render time (FR-013); no outbound HTTP
**Scale/Scope**: ~40+ tenant-scoped action surfaces; v1 migrates 36 exemplar surfaces
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | N/A | No Inventory changes |
| Read/write separation | ✔ | Helper enforces existing gates; no new writes |
| Graph contract path | N/A | No Graph calls |
| Deterministic capabilities | ✔ | Uses existing `Capabilities` registry |
| RBAC-UX planes | ✔ | Tenant-plane only; cross-plane logic untouched |
| Tenant isolation | ✔ | 404 for non-members; capability check requires membership first |
| Run observability | N/A | No long-running work; helper is request-scope only |
| Data minimization | ✔ | No additional logging beyond existing deny logs |
| Badge semantics | N/A | No badge changes |
## Existing RBAC Primitives (Research)
| Component | Location | Purpose |
|-----------|----------|---------|
| `Capabilities` | `app/Support/Auth/Capabilities.php` | Canonical tenant capability registry (constants) |
| `PlatformCapabilities` | `app/Support/Auth/PlatformCapabilities.php` | Platform-plane capabilities |
| `RoleCapabilityMap` | `app/Services/Auth/RoleCapabilityMap.php` | Role → capabilities mapping |
| `CapabilityResolver` | `app/Services/Auth/CapabilityResolver.php` | Request-scope cached role/capability resolution |
| `User::canAccessTenant()` | `app/Models/User.php:123` | Membership check |
| `AuthServiceProvider` | `app/Providers/AuthServiceProvider.php` | Registers Gates for all capabilities |
| Existing ad-hoc patterns | `app/Filament/**` | 50+ `->visible(fn ...)` / `->disabled(fn ...)` calls — target for migration |
## Project Structure
### Documentation (this feature)
```text
specs/066-rbac-ui-enforcement-helper/
├── spec.md # Feature specification
├── plan.md # This file
├── research.md # (no separate file needed — inline above)
├── data-model.md # (no schema changes)
├── quickstart.md # Adoption guide
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
```text
app/
├── Support/
│ └── Rbac/
│ ├── UiEnforcement.php # Central facade/builder
│ ├── TenantAccessContext.php # DTO: tenant, user, isMember, capabilityCheck
│ └── UiTooltips.php # Standardized tooltip strings
├── Services/Auth/
│ ├── CapabilityResolver.php # (existing, reused)
│ └── RoleCapabilityMap.php # (existing, reused)
├── Filament/
│ └── Resources/... # 36 exemplar migrations
tests/
├── Feature/
│ └── Rbac/
│ └── UiEnforcementTest.php # Integration tests
│ └── Guards/
│ └── NoAdHocFilamentAuthPatternsTest.php # CI-failing guard (file-scan)
├── Unit/
│ └── Support/Rbac/
│ └── UiEnforcementTest.php # Unit tests
```
**Structure Decision**: All new code lives in `app/Support/Rbac/` (helper) + tests; no new models/tables required.
## Key Design Decisions
### UiEnforcement API (FR-001)
```php
use App\Support\Rbac\UiEnforcement;
// Basic usage
UiEnforcement::forAction($action)
->requireMembership() // default: true
->requireCapability(Capabilities::PROVIDER_MANAGE)
->destructive() // optional: adds confirmation
->apply();
// Table/row action (receives record or record-accessor closure)
UiEnforcement::forTableAction(Action $action, Model|Closure $record)
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply();
// Mixed visibility support (keep business visibility, add RBAC visibility)
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
// Bulk action (all-or-nothing)
UiEnforcement::forBulkAction($action)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
```
Internally:
1. Resolves current tenant + user via `Filament::getTenant()` + `auth()->user()`
2. Checks membership via `CapabilityResolver` (request-scope cached)
3. Sets `->hidden()` for non-members (FR-002a)
4. Sets `->disabled()` + `->tooltip()` for members without capability (FR-004)
5. Wraps handler with server-side guard (FR-005): `abort(404)` / `abort(403)`
### Tooltip Copy (FR-008)
```php
class UiTooltips
{
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?';
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
}
```
### Destructive Confirmation (FR-007)
`->destructive()` calls:
- `$action->requiresConfirmation()`
- `$action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)`
- `$action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)`
### All-or-nothing Bulk (FR-010a)
Before rendering, bulk action checks all selected records. If any record fails capability check for the member, action is disabled.
### Guardrail (FR-011)
`tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` scans `app/Filament/**` for forbidden patterns like:
- `Gate::allows(...)` / `Gate::denies(...)`
- `abort_if(...)` / `abort_unless(...)`
It uses a legacy allowlist so CI fails only for **new** violations, and the allowlist should shrink as resources are migrated.
## v1 Migration Targets (FR-009)
| Surface | File | Current Pattern | Notes |
|---------|------|-----------------|-------|
| TenantResource table actions | `TenantResource.php` | Multiple `->visible(fn ...)` + `->disabled(fn ...)` | High-traffic, high-value |
| ProviderConnectionResource actions | `EditProviderConnection.php` | Multiple `canAccessTenant` + capability checks inline | Complex, good test case |
| BackupSetResource table actions | `BackupSetResource.php` | Many `->disabled(fn ...)` closures | Destructive actions |
| PolicyResource ListPolicies sync | `ListPolicies.php` | Inline checks | Good example |
| EntraGroupResource sync | `ListEntraGroups.php` | Inline checks | Good example |
| FindingResource actions | `FindingResource.php` | `->authorize(fn ...)` inline | Good example |
## Complexity Tracking
> No constitution violations. Complexity is low (helper + tests + 36 migrations).
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |

View File

@ -0,0 +1,262 @@
# Quickstart: UiEnforcement Helper
> Adoption guide for developers adding RBAC enforcement to Filament actions.
## TL;DR
Replace ad-hoc `->visible(fn ...)` / `->disabled(fn ...)` closures with `UiEnforcement`.
```php
// ❌ Before (ad-hoc)
Action::make('sync')
->visible(fn () => auth()->user()->can('provider:manage', Filament::getTenant()))
->disabled(fn () => ! auth()->user()->can('provider:manage', Filament::getTenant()))
->action(function () {
// no server-side guard
});
// ✅ After (UiEnforcement)
UiEnforcement::forAction(
Action::make('sync')
->action(fn () => $this->sync())
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply();
```
## When to Use
| Scenario | Use UiEnforcement? |
|----------|-------------------|
| Tenant-scoped action (header, table, bulk) | ✅ Yes |
| Platform-scoped action (/system panel) | ❌ No (use Gate directly) |
| Read-only navigation link | ❌ No (use `->visible()` for nav items) |
| Destructive action (delete, detach, restore) | ✅ Yes, with `->destructive()` |
## API Reference
### Header / Page Actions
```php
use App\Support\Rbac\UiEnforcement;
use App\Support\Auth\Capabilities;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('createBackup')
->action(fn () => $this->createBackup())
)
->requireCapability(Capabilities::BACKUP_CREATE)
->apply(),
UiEnforcement::forAction(
Action::make('deleteAllBackups')
->action(fn () => $this->deleteAll())
)
->requireCapability(Capabilities::BACKUP_MANAGE)
->destructive()
->apply(),
];
}
```
### Table Row Actions
```php
public static function table(Table $table): Table
{
return $table
->columns([...])
->actions([
UiEnforcement::forTableAction(
Action::make('restore')
->action(fn (Policy $record) => $record->restore()),
fn () => $this->getRecord() // record accessor
)
->requireCapability(Capabilities::RESTORE_EXECUTE)
->destructive()
->apply(),
]);
}
```
### Bulk Actions
```php
->bulkActions([
UiEnforcement::forBulkAction(
BulkAction::make('deleteSelected')
->action(fn (Collection $records) => $records->each->delete())
)
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
])
```
## Behavior Matrix
| User Status | UI State | Server Response |
|-------------|----------|-----------------|
| Non-member | Hidden | Blocked (no execution, 200) |
| Member, no capability | Visible, disabled + tooltip | Blocked (no execution, 200) |
| Member, has capability | Enabled | Executes |
| Member, destructive action | Confirmation modal | Executes after confirm |
> **Note on 404/403 Responses:** In Filament v5, hidden actions are automatically
> treated as disabled, so execution is blocked silently (returns 200 with no side
> effects). True 404 enforcement happens at the page/routing level via tenant
> middleware. The UiEnforcement helper includes defense-in-depth server-side
> guards that abort(404/403) if somehow reached, but the primary protection is
> Filament's isHidden/isDisabled chain.
## Tooltip Customization
Default tooltip: *"You don't have permission to do this. Ask a tenant admin."*
Override per-action:
```php
UiEnforcement::forAction($action)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('Contact your organization owner to enable this feature.')
->apply();
```
## Testing
Test both UI state and execution blocking:
```php
it('hides sync action for non-members', function () {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
// user is NOT a member
actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionHidden('sync');
});
it('blocks action execution for non-members (no side effects)', function () {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
Queue::fake();
actingAs($user);
Filament::setTenant($tenant, true);
// Hidden actions are blocked silently (200 but no execution)
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertSuccessful();
// Verify no side effects occurred
Queue::assertNothingPushed();
});
it('disables sync action for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionDisabled('sync');
});
it('shows disabled tooltip for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionHasTooltip('sync', UiTooltips::INSUFFICIENT_PERMISSION);
});
```
## Migration Checklist
When migrating an existing action:
- [ ] Remove `->visible(fn ...)` closure (UiEnforcement handles this)
- [ ] Remove `->disabled(fn ...)` closure (UiEnforcement handles this)
- [ ] Remove inline `Gate::check()` / `abort_unless()` from action handler
- [ ] Wrap action with `UiEnforcement::forAction(...)->requireCapability(...)->apply()`
- [ ] Add `->destructive()` if action modifies/deletes data
- [ ] Add test for non-member (hidden + no execution)
- [ ] Add test for member without capability (disabled + tooltip)
- [ ] Add test for member with capability (enabled + executes)
### Real Example: ListPolicies Sync Action
```php
// Before (ad-hoc)
Action::make('sync')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
// ... sync logic
})
->disabled(fn (): bool => ! Gate::allows(Capabilities::TENANT_SYNC, Tenant::current()))
// After (UiEnforcement)
UiEnforcement::forAction(
Action::make('sync')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
// ... sync logic
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->destructive()
->apply()
```
## Common Mistakes
### ❌ Forgetting `->apply()`
```php
// This does nothing!
UiEnforcement::forAction($action)
->requireCapability(Capabilities::PROVIDER_MANAGE);
// missing ->apply()
```
### ❌ Using with non-tenant panels
```php
// UiEnforcement is tenant-scoped only!
// For /system panel, use Gate::check() directly
```
### ❌ Mixing old and new patterns
```php
// Don't mix - pick one
UiEnforcement::forAction(
Action::make('sync')
->visible(fn () => someOtherCheck()) // ❌ conflict
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply();
```
## Questions?
See [spec.md](./spec.md) for full requirements or [plan.md](./plan.md) for implementation details.

View File

@ -0,0 +1,163 @@
# Feature Specification: RBAC UI Enforcement Helper v1
**Feature Branch**: `066-rbac-ui-enforcement-helper`
**Created**: 2026-01-28
**Status**: Draft
**Input**: Provide a suite-wide, consistent way to enforce tenant RBAC for admin UI actions (buttons/actions in lists, records, and bulk actions) without copy/paste authorization logic.
## Clarifications
### Session 2026-01-28
- Q: For Bulk Actions with mixed-permission records (some authorized, some not), what should the default behavior be? → A: All-or-nothing (if any selected record would be unauthorized, the bulk action is disabled for members and execution fails with 403 for members / 404 for non-members).
- Q: Should the helper render actions at all for non-members (in case a tenant page is reachable via misrouting), or always hide them? → A: Hide for non-members in UI, but still enforce 404 server-side for any execution attempt.
- Q: How strict should the “no ad-hoc authorization patterns in app/Filament/**” guard be in v1? → A: CI-failing (new ad-hoc patterns fail tests/CI).
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Tenant member sees consistent disabled UX (Priority: P1)
As a tenant member, I can clearly see which actions exist, and when I lack permission the action is visible but disabled with an explanatory tooltip.
**Why this priority**: Prevents confusion and reduces support load while keeping the UI predictable for members.
**Independent Test**: Can be tested by visiting a tenant-scoped admin page as a member with insufficient permissions and verifying the action is disabled, shows the standard tooltip, and cannot be executed.
**Acceptance Scenarios**:
1. **Given** a tenant member without the required capability, **When** they view an action on a tenant-scoped page, **Then** the action is visible but disabled and shows the standard “insufficient permission” tooltip.
2. **Given** a tenant member without the required capability, **When** they attempt to execute the action (including direct invocation, bypassing the UI), **Then** the server rejects with 403.
---
### User Story 2 - Non-members cannot infer tenant resources (Priority: P2)
As a non-member of a tenant, I cannot discover tenant-scoped resources or actions; the system responds as “not found”.
**Why this priority**: Prevents tenant enumeration and cross-tenant information leakage.
**Independent Test**: Can be tested by attempting to access tenant-scoped pages/actions as a user without membership and verifying 404 behavior.
**Acceptance Scenarios**:
1. **Given** a user who is not entitled to the tenant scope, **When** they attempt any tenant-scoped page or action, **Then** the system responds as 404 (deny-as-not-found).
---
### User Story 3 - Maintainers add actions safely by default (Priority: P3)
As a maintainer, I can add new tenant-scoped actions using one standard pattern, and regression guards prevent introducing ad-hoc authorization logic.
**Why this priority**: Reduces RBAC regressions as the app grows and makes reviews easier.
**Independent Test**: Can be tested by introducing a sample ad-hoc authorization pattern and confirming automated checks/tests flag it.
**Acceptance Scenarios**:
1. **Given** a maintainer adds a new tenant-scoped action, **When** they use the central enforcement helper, **Then** member/non-member semantics and tooltip behavior match the standard without additional per-page customization.
2. **Given** a maintainer introduces a new ad-hoc authorization mapping in tenant-scoped admin UI code, **When** automated checks run, **Then** the change is flagged to prevent drift.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- Membership is revoked while the user has the page open (execution must still enforce 404 semantics).
- Capability changes mid-session (UI may be stale; server enforcement remains correct).
- Bulk actions with mixed-permission records: all-or-nothing (disable + tooltip for members; 403 on execution for members; 404 semantics for non-members).
- Target record is deleted/archived between render and execution (no information leakage in errors).
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** This feature defines a default pattern for tenant-plane admin actions. The implementation MUST:
- enforce membership as an isolation boundary (non-member / not entitled → 404 deny-as-not-found),
- enforce capability denials as 403 (after membership is established),
- keep actions visible-but-disabled with a standard tooltip for members lacking capability (except allowed sensitive exceptions),
- enforce authorization server-side for every mutation/operation-start/credential change,
- use the canonical capability registry (no raw capability string literals),
- ensure destructive-like actions require confirmation,
- ship regression tests and a guard against new ad-hoc authorization patterns.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: The system MUST provide a single, centrally maintained enforcement mechanism that can be applied to tenant-scoped admin actions (including header actions, record actions, and bulk actions).
- **FR-002**: For tenant-scoped actions, the system MUST enforce membership as deny-as-not-found: users not entitled to the tenant scope MUST receive 404 semantics for action execution.
- **FR-002a**: For users not entitled to the tenant scope, the UI SHOULD NOT render tenant-scoped actions (default: hidden), while server-side execution MUST still enforce 404 semantics.
- **FR-003**: For tenant members, the system MUST enforce capability denial as 403 when executing an action without permission.
- **FR-004**: For tenant members lacking capability, the UI MUST render actions as visible-but-disabled and MUST show a standard tooltip explaining the missing permission.
- **FR-005**: The enforcement mechanism MUST also enforce the same rules server-side (UI state is never sufficient).
- **FR-006**: The enforcement mechanism MUST be capability-first and MUST reference capabilities only via the canonical capability registry (no ad-hoc string literals).
- **FR-007**: The enforcement mechanism MUST provide a standard confirmation behavior for destructive-like actions, including a clear warning message.
- **FR-008**: The system MUST provide standardized, non-leaky error and tooltip messages:
- 404 semantics for non-members without hints.
- 403 responses for insufficient capability without object details.
- **FR-009**: v1 MUST include limited adoption by migrating 36 exemplar action surfaces to the new pattern to prove the approach.
- **FR-010**: v1 MUST include regression tests that cover: non-member → 404, member without capability → disabled UI + 403 on execution, member with capability → allowed.
- **FR-010a**: For bulk actions with mixed-permission records, the default behavior MUST be all-or-nothing (members see disabled + tooltip; execution denies with 403; non-members receive 404 semantics).
- **FR-011**: v1 MUST include an automated, CI-failing guard that flags new ad-hoc authorization patterns in tenant-scoped admin UI code.
- **FR-012**: The enforcement mechanism MUST avoid introducing avoidable performance regressions (no per-record membership lookups during render).
- **FR-013**: The enforcement mechanism MUST NOT trigger outbound HTTP calls during render; it is DB-only.
### Key Entities *(include if feature involves data)*
- **Tenant**: The isolation boundary for all tenant-scoped UI and actions.
- **User**: The authenticated actor attempting to view or execute actions.
- **Membership**: Whether a user is entitled to a tenant scope.
- **Capability**: A named permission from the canonical capability registry.
- **Action**: A discrete operation exposed in the tenant-scoped admin interface.
### Assumptions
- Default tooltip language is English (i18n may be added later).
- Non-destructive bulk actions are in scope for v1; destructive bulk actions may be supported but are not required for v1 completion.
- Global search tenant scoping is out of scope for this spec (covered by separate work), but this feature must not introduce new leaks.
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: For all migrated tenant-scoped action surfaces, 100% of non-member execution attempts are denied with 404 semantics (verified by automated tests).
- **SC-002**: For all migrated tenant-scoped action surfaces, 100% of member-but-unauthorized execution attempts are denied with 403 (verified by automated tests).
- **SC-003**: For all migrated tenant-scoped action surfaces, members lacking capability see the action visible-but-disabled with the standard tooltip (verified by automated tests and/or UI assertions).
- **SC-004**: At least one automated guard exists that flags newly introduced ad-hoc authorization patterns in tenant-scoped admin UI code.
- **SC-005**: v1 demonstrates adoption by migrating 36 exemplar action surfaces, reducing duplicate authorization wiring in those areas.

View File

@ -0,0 +1,254 @@
# Tasks: RBAC UI Enforcement Helper v1
**Input**: Design documents from `/specs/066-rbac-ui-enforcement-helper/`
**Prerequisites**: plan.md ✓, spec.md ✓, quickstart.md ✓
**Tests**: REQUIRED (Pest) — this feature changes runtime authorization behavior.
**RBAC**: This feature IS the RBAC enforcement helper — all tasks enforce constitution RBAC-UX rules.
**Organization**: Tasks grouped by user story for independent implementation.
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: US1/US2/US3 for user story phases; omitted for Setup/Foundational/Polish
---
## Phase 1: Setup
**Purpose**: Create helper infrastructure with no external dependencies
- [X] T001 Create directory structure `app/Support/Rbac/`
- [X] T002 [P] Create `UiTooltips.php` with tooltip constants in `app/Support/Rbac/UiTooltips.php`
- [X] T003 [P] Create `TenantAccessContext.php` DTO in `app/Support/Rbac/TenantAccessContext.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core `UiEnforcement` helper — MUST complete before any user story tests
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [X] T004 Implement `UiEnforcement::forAction()` static method in `app/Support/Rbac/UiEnforcement.php`
- [X] T005 Implement `->requireMembership()` method (default: true) in `app/Support/Rbac/UiEnforcement.php`
- [X] T006 Implement `->requireCapability(string $capability)` method in `app/Support/Rbac/UiEnforcement.php`
- [X] T007 Implement `->destructive()` method (confirmation modal) in `app/Support/Rbac/UiEnforcement.php`
- [X] T008 Implement `->tooltip(string $message)` override method in `app/Support/Rbac/UiEnforcement.php`
- [X] T009 Implement `->apply()` method (sets hidden/disabled/guards) in `app/Support/Rbac/UiEnforcement.php`
- [X] T010 Implement `UiEnforcement::forTableAction()` static method in `app/Support/Rbac/UiEnforcement.php`
- [X] T011 Implement `UiEnforcement::forBulkAction()` static method with all-or-nothing logic in `app/Support/Rbac/UiEnforcement.php`
**Checkpoint**: `UiEnforcement` class ready — user story tests can now be written
---
## Phase 3: User Story 1 — Tenant member sees consistent disabled UX (Priority: P1) 🎯 MVP
**Goal**: Members lacking capability see actions visible-but-disabled with standard tooltip; 403 on execution
**Independent Test**: Visit tenant page as member with insufficient permission → action disabled with tooltip, cannot execute
### Tests for User Story 1
- [X] T012 [P] [US1] Test: member without capability sees disabled action + tooltip in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php`
- [X] T013 [P] [US1] Test: member without capability is blocked from execution in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php`
- [X] T014 [P] [US1] Test: member with capability sees enabled action + can execute in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php`
- [X] T014a [P] [US1] Test: destructive action shows confirmation modal before execution in `tests/Feature/Rbac/UiEnforcementDestructiveTest.php`
### Implementation for User Story 1
- [X] T015 [US1] Validate `->apply()` correctly sets `->disabled()` + `->tooltip()` for members lacking capability (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php`
- [X] T016 [US1] Validate `->apply()` correctly blocks unauthorized execution (via Filament's isDisabled check + defense-in-depth abort) in `app/Support/Rbac/UiEnforcement.php`
- ~~T017 [US1] Migrate TenantResource table actions to UiEnforcement~~ **OUT OF SCOPE v1**: TenantResource is record==tenant, not tenant-scoped
- [X] T018 [US1] Migrate ProviderConnectionResource actions to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/ProviderConnectionResource.php`
**Checkpoint**: US1 complete — members see consistent disabled UX with tooltip (exemplar: ListPolicies)
---
## Phase 4: User Story 2 — Non-members cannot infer tenant resources (Priority: P2)
**Goal**: Non-members receive 404 (deny-as-not-found) for all tenant-scoped actions; actions hidden in UI
**Independent Test**: Access tenant page as non-member → actions hidden, execution returns 404
### Tests for User Story 2
- [X] T019 [P] [US2] Test: non-member sees action hidden in UI in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php`
- [X] T020 [P] [US2] Test: non-member action is blocked (via Filament hidden-action semantics) in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php`
- [X] T021 [P] [US2] Test: membership revoked mid-session still enforces protection in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php`
### Implementation for User Story 2
- [X] T022 [US2] Validate `->apply()` correctly sets `->hidden()` for non-members (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php`
- [X] T023 [US2] Validate `->apply()` blocks non-member execution (via Filament's isHidden → isDisabled chain; 404 server-side guard is defense-in-depth) in `app/Support/Rbac/UiEnforcement.php`
- [X] T024 [US2] Migrate BackupSetResource actions (row + bulk) to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/BackupSetResource.php`
- [X] T025 [US2] Migrate PolicyResource sync actions to UiEnforcement in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
**Checkpoint**: US2 complete — non-members receive 404 semantics, no information leakage
---
## Phase 5: User Story 3 — Maintainers add actions safely by default (Priority: P3)
**Goal**: CI-failing guard flags new ad-hoc authorization patterns; standard pattern documented
**Independent Test**: Introduce ad-hoc `Gate::allows` or `abort_unless()` in Filament → guard test fails
### Tests for User Story 3
- [X] T026 [P] [US3] Guard test: scan `app/Filament/**` for forbidden ad-hoc patterns (Gate + abort helpers) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`
- [X] T027 [P] [US3] Unit test: UiEnforcement uses only canonical Capabilities constants in `tests/Unit/Support/Rbac/UiEnforcementTest.php`
### Implementation for User Story 3
- [X] T028 [US3] Replace Pest-Arch guard with stable file-scan guard (CI-failing, allowlist for legacy only) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`
- [X] T029 [US3] Migrate EntraGroupResource sync actions to UiEnforcement in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- [X] T030 [US3] Remove Gate facade usage from FindingResource (migrate auth to canonical checks) in `app/Filament/Resources/FindingResource.php`
**Checkpoint**: US3 complete — guardrail prevents regression (file-scan), exemplar surfaces migrated
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Cleanup, additional tests, documentation
- [X] T031 [P] PHPDoc blocks present on all public methods in `app/Support/Rbac/UiEnforcement.php`
- [X] T032 [P] Update quickstart.md with migration examples in `specs/066-rbac-ui-enforcement-helper/quickstart.md`
- [X] T033 Run Pint formatter on new files with `vendor/bin/sail bin pint app/Support/Rbac`
- [X] T034 Run full test suite with `vendor/bin/sail artisan test --compact` — 837 passed, 5 skipped
- [X] T035 Validate quickstart.md examples work in codebase (ListPolicies migration verified)
---
## Phase 7: Follow-up — Findings capability cleanup (Mini-feature)
**Purpose**: Avoid overloading broad capabilities (e.g. `TENANT_SYNC`) for findings acknowledgement.
- [X] T036 Add `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` in `app/Support/Auth/Capabilities.php`
- [X] T037 Grant `TENANT_FINDINGS_ACKNOWLEDGE` to Owner/Manager/Operator (not Readonly) + update role-matrix tests
- [X] T038 Update Finding list acknowledge action to require `TENANT_FINDINGS_ACKNOWLEDGE` in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- [X] T039 Refactor `FindingPolicy::update()` to use `CapabilityResolver` with `TENANT_FINDINGS_ACKNOWLEDGE` (remove ad-hoc `Gate::forUser(...)->allows(...)`)
---
## Phase 8: Follow-up — Legacy allowlist shrink (Stepwise)
**Purpose**: Keep shrinking the Filament guard allowlist with one-file migrations.
- [X] T040 Remove `BackupScheduleResource.php` from the legacy allowlist after migration
- [X] T041 Migrate `ListEntraGroupSyncRuns.php` to UiEnforcement + add a focused Livewire test
- [X] T042 Remove `ListEntraGroupSyncRuns.php` from the legacy allowlist after migration
- [X] T043 Migrate `ListProviderConnections.php` create action to UiEnforcement + add a focused Livewire test
- [X] T044 Remove `ListProviderConnections.php` from the legacy allowlist after migration
- [X] T045 Migrate `DriftLanding.php` generation permission check to `CapabilityResolver` (remove Gate facade) + add a focused Livewire test
- [X] T046 Remove `DriftLanding.php` from the legacy allowlist after migration
- [X] T047 Migrate `RegisterTenant.php` page-level checks to `CapabilityResolver` + replace `abort_unless()` with `abort()`
- [X] T048 Remove `RegisterTenant.php` from the legacy allowlist after migration
- [X] T049 Migrate `EditProviderConnection.php` actions + save guards to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test
- [X] T050 Remove `EditProviderConnection.php` from the legacy allowlist after migration
- [X] T051 Migrate `CreateRestoreRun.php` page authorization to `CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test
- [X] T052 Remove `CreateRestoreRun.php` from the legacy allowlist after migration
- [X] T053 Migrate `InventoryItemResource.php` resource authorization to `CapabilityResolver` (remove Gate facade) + add a focused Pest test
- [X] T054 Remove `InventoryItemResource.php` from the legacy allowlist after migration
- [X] T055 Migrate `VersionsRelationManager.php` restore action to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test
- [X] T056 Remove `VersionsRelationManager.php` from the legacy allowlist after migration
- [X] T057 Migrate `BackupItemsRelationManager.php` actions to `UiEnforcement` (remove Gate facade) + add a focused Livewire test
- [X] T058 Remove `BackupItemsRelationManager.php` from the legacy allowlist after migration
- [X] T059 Migrate `PolicyVersionResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) while preserving metadata-only restore behavior
- [X] T060 Remove `PolicyVersionResource.php` from the legacy allowlist after migration
- [X] T061 Migrate `RestoreRunResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless)
- [X] T062 Remove `RestoreRunResource.php` from the legacy allowlist after migration
- [X] T063 Fix `UiEnforcement` server-side guard to use Filament lifecycle hooks (`->before()`) to preserve Filament action parameter injection
- [X] T064 Migrate `PolicyResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless)
- [X] T065 Remove `PolicyResource.php` from the legacy allowlist after migration
- [X] T066 Migrate `EditTenant.php` archive action off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless)
- [X] T067 Remove `EditTenant.php` from the legacy allowlist after migration
- [X] T068 Migrate `TenantMembershipsRelationManager.php` actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade)
- [X] T069 Remove `TenantMembershipsRelationManager.php` from the legacy allowlist after migration
- [X] T070 Migrate `TenantResource.php` off ad-hoc patterns to `CapabilityResolver` (remove Gate facade + abort_unless)
- [X] T071 Remove `TenantResource.php` from the legacy allowlist after migration
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Stories (Phase 35)**: All depend on Foundational; can proceed in parallel or by priority
- **Polish (Phase 6)**: Depends on all user stories
### User Story Dependencies
- **US1 (P1)**: Foundational only — no cross-story dependencies
- **US2 (P2)**: Foundational only — no cross-story dependencies
- **US3 (P3)**: Foundational only — no cross-story dependencies
### Within Each User Story
- Tests MUST be written FIRST and FAIL before implementation
- Wire logic in `UiEnforcement.php` before migrating Filament surfaces
- Migrate surfaces one at a time, verify tests pass
### Parallel Opportunities
- T002 + T003 (Setup) can run in parallel
- All test tasks (T012T014, T019T021, T026T027) can run in parallel
- US1, US2, US3 can run in parallel after Foundational
- T031 + T032 (Polish) can run in parallel
---
## Parallel Example: User Story 1
```bash
# Launch all tests for US1 together:
T012: "Test: member without capability sees disabled action + tooltip"
T013: "Test: member without capability receives 403 on execution"
T014: "Test: member with capability sees enabled action + can execute"
# Then implement sequentially:
T015 → T016 → T017 → T018
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001T003)
2. Complete Phase 2: Foundational (T004T011)
3. Complete Phase 3: User Story 1 (T012T018)
4. **STOP and VALIDATE**: Members see disabled + tooltip + 403
5. Deploy/demo if ready
### Incremental Delivery
1. Setup + Foundational → `UiEnforcement` ready
2. US1 → Consistent disabled UX for members (MVP!)
3. US2 → Non-member 404 enforcement
4. US3 → CI-failing guardrail + all 6 surfaces migrated
5. Polish → Docs, cleanup, full test suite
---
## Summary
| Metric | Count |
|--------|-------|
| Total tasks | 40 |
| Setup tasks | 3 |
| Foundational tasks | 8 |
| US1 tasks | 8 |
| US2 tasks | 7 |
| US3 tasks | 5 |
| Polish tasks | 5 |
| Follow-up tasks | 4 |
| Parallel opportunities | 13 |
| MVP scope | Phases 13 (T001T018) |

View File

@ -18,17 +18,16 @@
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
$thrown = null; $component = Livewire::test(ListFindings::class)
->assertTableBulkActionVisible('acknowledge_selected')
->assertTableBulkActionDisabled('acknowledge_selected');
try { try {
Livewire::test(ListFindings::class) $component->callTableBulkAction('acknowledge_selected', $findings);
->callTableBulkAction('acknowledge_selected', $findings); } catch (Throwable) {
} catch (Throwable $exception) { // Filament actions may abort/throw when forced to execute.
$thrown = $exception;
} }
expect($thrown)->not->toBeNull();
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
}); });
@ -45,16 +44,15 @@
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
]); ]);
$thrown = null; $component = Livewire::test(ListFindings::class)
->assertActionVisible('acknowledge_all_matching')
->assertActionDisabled('acknowledge_all_matching');
try { try {
Livewire::test(ListFindings::class) $component->callAction('acknowledge_all_matching');
->callAction('acknowledge_all_matching'); } catch (Throwable) {
} catch (Throwable $exception) { // Filament actions may abort/throw when forced to execute.
$thrown = $exception;
} }
expect($thrown)->not->toBeNull();
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
}); });

View File

@ -7,6 +7,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -18,6 +19,7 @@
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$tenant->makeCurrent(); $tenant->makeCurrent();
Filament::setTenant($tenant, true);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -74,9 +74,12 @@
'ownerRecord' => $tenant, 'ownerRecord' => $tenant,
'pageClass' => ViewTenant::class, 'pageClass' => ViewTenant::class,
]) ])
->assertTableActionHidden('add_member') ->assertTableActionVisible('add_member')
->assertTableActionHidden('change_role', $membership) ->assertTableActionDisabled('add_member')
->assertTableActionHidden('remove', $membership); ->assertTableActionVisible('change_role', $membership)
->assertTableActionDisabled('change_role', $membership)
->assertTableActionVisible('remove', $membership)
->assertTableActionDisabled('remove', $membership);
}); });
it('prevents removing or demoting the last owner', function (): void { it('prevents removing or demoting the last owner', function (): void {

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* CI guard: prevent new ad-hoc auth patterns in Filament.
*
* Rationale:
* - We want UiEnforcement (and centralized RBAC services) to be the default.
* - Gate::allows/denies, abort_if/unless, and similar ad-hoc patterns tend to drift.
* - We allowlist legacy files so CI only fails on NEW violations.
*
* If you migrate a legacy file to UiEnforcement, remove it from the allowlist.
*/
describe('Filament auth guard (no new ad-hoc patterns)', function () {
it('fails if new files introduce forbidden auth patterns under app/Filament/**', function () {
$filamentDir = base_path('app/Filament');
expect(is_dir($filamentDir))->toBeTrue("Filament directory not found: {$filamentDir}");
/**
* Legacy allowlist: these files currently contain forbidden patterns.
*
* IMPORTANT:
* - Do NOT add new entries casually.
* - The goal is to shrink this list over time.
*
* Paths are workspace-relative (e.g. app/Filament/Resources/Foo.php).
*/
$legacyAllowlist = [
// Pages (page-level authorization or legacy patterns)
];
$patterns = [
// Gate facade usage
'/\\bGate::(allows|denies|check|authorize)\\b/',
'/^\\s*use\\s+Illuminate\\\\Support\\\\Facades\\\\Gate\\s*;\\s*$/m',
// Ad-hoc abort helpers
'/\\babort_(if|unless)\\s*\\(/',
];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($filamentDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var array<string, array<int, string>> $violations */
$violations = [];
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$absolutePath = $file->getPathname();
$relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $absolutePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
if (in_array($relativePath, $legacyAllowlist, true)) {
continue;
}
$content = file_get_contents($absolutePath);
if (! is_string($content)) {
continue;
}
$lines = preg_split('/\\R/', $content) ?: [];
foreach ($lines as $lineNumber => $line) {
foreach ($patterns as $pattern) {
if (preg_match($pattern, $line) === 1) {
$violations[$relativePath][] = ($lineNumber + 1).': '.trim($line);
}
}
}
}
if ($violations !== []) {
$messageLines = [
'Forbidden ad-hoc auth patterns detected in app/Filament/**.',
'Migrate to UiEnforcement (preferred) or add a justified temporary entry to the legacy allowlist.',
'',
];
foreach ($violations as $path => $hits) {
$messageLines[] = $path;
foreach ($hits as $hit) {
$messageLines[] = ' - '.$hit;
}
}
expect($violations)->toBeEmpty(implode("\n", $messageLines));
}
expect(true)->toBeTrue();
});
});

View File

@ -161,7 +161,7 @@
Livewire::test(ListInventoryItems::class) Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
->assertStatus(403); ->assertSuccessful();
Queue::assertNothingPushed(); Queue::assertNothingPushed();

View File

@ -46,9 +46,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk() ->assertOk();
->assertDontSee('Update credentials')
->assertDontSee('Disable connection');
}); });
test('readonly users can view provider connections but cannot manage them', function () { test('readonly users can view provider connections but cannot manage them', function () {
@ -69,9 +67,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk() ->assertOk();
->assertDontSee('Update credentials')
->assertDontSee('Disable connection');
}); });
test('provider connection edit is not accessible cross-tenant', function () { test('provider connection edit is not accessible cross-tenant', function () {

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Models\BackupItem;
use App\Models\BackupSet;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
describe('Backup items relation manager UI enforcement', function () {
it('shows add policies as visible but disabled for readonly members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
$item = BackupItem::factory()->for($backupSet)->for($tenant)->create();
Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertTableActionVisible('addPolicies')
->assertTableActionDisabled('addPolicies')
->assertTableActionExists('addPolicies', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to add policies.';
})
->assertTableBulkActionVisible('bulk_remove')
->assertTableBulkActionDisabled('bulk_remove', [$item]);
});
it('shows add policies as enabled for owner members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
$item = BackupItem::factory()->for($backupSet)->for($tenant)->create();
Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertTableActionVisible('addPolicies')
->assertTableActionEnabled('addPolicies')
->assertTableBulkActionVisible('bulk_remove')
->assertTableBulkActionEnabled('bulk_remove', [$item]);
});
it('hides actions after membership is revoked mid-session', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create();
$component = Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertTableActionVisible('addPolicies')
->assertTableActionEnabled('addPolicies')
->assertTableBulkActionVisible('bulk_remove');
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
$component
->call('$refresh')
->assertTableActionHidden('addPolicies')
->assertTableBulkActionHidden('bulk_remove');
});
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
describe('Create restore run page authorization', function () {
it('returns 404 for non-members (deny as not found)', function () {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class)
->assertStatus(404);
});
it('returns 403 for members without tenant manage capability', function () {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class)
->assertStatus(403);
});
});

View File

@ -0,0 +1,65 @@
<?php
use App\Filament\Pages\DriftLanding;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\InventorySyncRun;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
describe('Drift landing generate permission', function () {
it('blocks generation for readonly members (no tenant sync)', function () {
Bus::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'finished_at' => now()->subDays(2),
]);
InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'finished_at' => now()->subDay(),
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(DriftLanding::class)
->assertSet('state', 'blocked')
->assertSet('message', 'You can view existing drift findings and run history, but you do not have permission to generate drift.');
Bus::assertNotDispatched(GenerateDriftFindingsJob::class);
});
it('starts generation for owner members (tenant sync allowed)', function () {
Bus::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'finished_at' => now()->subDays(2),
]);
$latestRun = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'finished_at' => now()->subDay(),
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(DriftLanding::class)
->assertSet('state', 'generating')
->assertSet('scopeKey', (string) $latestRun->selection_hash);
$operationRunId = $component->get('operationRunId');
expect($operationRunId)->toBeInt()->toBeGreaterThan(0);
Bus::assertDispatched(GenerateDriftFindingsJob::class);
});
});

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Models\ProviderConnection;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
describe('Edit provider connection actions UI enforcement', function () {
it('shows enable connection action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'status' => 'disabled',
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->assertActionVisible('enable_connection')
->assertActionDisabled('enable_connection')
->assertActionExists('enable_connection', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to manage provider connections.';
})
->mountAction('enable_connection')
->callMountedAction()
->assertSuccessful();
$connection->refresh();
expect($connection->status)->toBe('disabled');
});
it('shows disable connection action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'status' => 'connected',
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->assertActionVisible('disable_connection')
->assertActionDisabled('disable_connection')
->assertActionExists('disable_connection', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to manage provider connections.';
})
->mountAction('disable_connection')
->callMountedAction()
->assertSuccessful();
$connection->refresh();
expect($connection->status)->toBe('connected');
});
it('shows enable connection action as enabled for owner members', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'status' => 'disabled',
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->assertActionVisible('enable_connection')
->assertActionEnabled('enable_connection');
});
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
describe('Edit tenant archive action UI enforcement', function () {
it('shows archive action as visible but disabled for manager members', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionDisabled('archive')
->assertActionExists('archive', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to archive tenants.';
})
->mountAction('archive')
->callMountedAction()
->assertSuccessful();
$tenant->refresh();
expect($tenant->trashed())->toBeFalse();
});
it('allows owner members to archive tenant', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionEnabled('archive')
->mountAction('archive')
->callMountedAction()
->assertHasNoActionErrors();
$tenant->refresh();
expect($tenant->trashed())->toBeTrue();
});
});

View File

@ -0,0 +1,66 @@
<?php
use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
describe('Entra group sync runs UI enforcement', function () {
beforeEach(function () {
Queue::fake();
Notification::fake();
});
it('hides sync action for non-members', function () {
// Mount as a valid tenant member first, then revoke membership mid-session.
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups');
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
$component->assertActionHidden('sync_groups');
Queue::assertNothingPushed();
});
it('shows sync action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups')
->assertActionDisabled('sync_groups');
Queue::assertNothingPushed();
});
it('allows owner members to execute sync action (dispatches job)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups')
->assertActionEnabled('sync_groups')
->mountAction('sync_groups')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(EntraGroupSyncJob::class);
});
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
describe('Inventory item resource authorization', function () {
it('is not visible for non-members', function () {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
$this->actingAs($user);
$tenant->makeCurrent();
expect(InventoryItemResource::canViewAny())->toBeFalse();
});
it('is visible for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
expect(InventoryItemResource::canViewAny())->toBeTrue();
});
it('prevents viewing inventory items from other tenants', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$otherTenant = Tenant::factory()->create();
$this->actingAs($user);
$tenant->makeCurrent();
$record = InventoryItem::factory()->create([
'tenant_id' => $otherTenant->getKey(),
]);
expect(InventoryItemResource::canView($record))->toBeFalse();
});
it('allows viewing inventory items from the current tenant', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
$record = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
expect(InventoryItemResource::canView($record))->toBeTrue();
});
});

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
describe('Policy versions relation manager restore-to-Intune UI enforcement', function () {
it('disables restore action for readonly members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'metadata' => [],
]);
Livewire::actingAs($user)
->test(VersionsRelationManager::class, [
'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class,
])
->assertTableActionDisabled('restore_to_intune', $version);
});
it('disables restore action for metadata-only snapshots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'metadata' => ['source' => 'metadata_only'],
]);
Livewire::actingAs($user)
->test(VersionsRelationManager::class, [
'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class,
])
->assertTableActionDisabled('restore_to_intune', $version);
});
it('hides restore action after membership is revoked mid-session', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'metadata' => [],
]);
$component = Livewire::actingAs($user)
->test(VersionsRelationManager::class, [
'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class,
]);
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
$component
->call('$refresh')
->assertTableActionHidden('restore_to_intune', $version);
});
});

View File

@ -0,0 +1,54 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
describe('Provider connections create action UI enforcement', function () {
it('shows create action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionDisabled('create')
->assertActionExists('create', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to create provider connections.';
});
});
it('shows create action as enabled for owner members', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionEnabled('create');
});
it('hides create action after membership is revoked mid-session', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionEnabled('create');
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
$component
->call('$refresh')
->assertActionHidden('create');
});
});

View File

@ -0,0 +1,23 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
describe('Register tenant page authorization', function () {
it('is not visible for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
expect(RegisterTenant::canView())->toBeFalse();
});
it('is visible for owner members', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
expect(RegisterTenant::canView())->toBeTrue();
});
});

View File

@ -10,6 +10,8 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue();

View File

@ -10,6 +10,8 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue();

View File

@ -10,6 +10,8 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue();

View File

@ -15,6 +15,8 @@
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse();

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
use App\Models\Tenant;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
describe('Tenant memberships relation manager UI enforcement', function () {
it('shows membership actions as visible but disabled for manager members', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$otherUser = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'readonly');
Livewire::test(TenantMembershipsRelationManager::class, [
'ownerRecord' => $tenant,
'pageClass' => EditTenant::class,
])
->assertTableActionVisible('add_member')
->assertTableActionDisabled('add_member')
->assertTableActionExists('add_member', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
})
->assertTableActionVisible('change_role')
->assertTableActionDisabled('change_role')
->assertTableActionExists('change_role', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
})
->assertTableActionVisible('remove')
->assertTableActionDisabled('remove')
->assertTableActionExists('remove', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
});
});
});

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
describe('Tenant resource authorization', function () {
it('cannot be created by non-members', function () {
$user = User::factory()->create();
$this->actingAs($user);
expect(TenantResource::canCreate())->toBeFalse();
});
it('can be created by managers (TENANT_MANAGE)', function () {
[$user] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
expect(TenantResource::canCreate())->toBeTrue();
});
it('can be edited by managers (TENANT_MANAGE)', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
expect(TenantResource::canEdit($tenant))->toBeTrue();
});
it('cannot be deleted by managers (TENANT_DELETE)', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
expect(TenantResource::canDelete($tenant))->toBeFalse();
});
it('can be deleted by owners (TENANT_DELETE)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
expect(TenantResource::canDelete($tenant))->toBeTrue();
});
it('cannot edit tenants it cannot access', function () {
[$user] = createUserWithTenant(role: 'manager');
$otherTenant = Tenant::factory()->create();
$this->actingAs($user);
expect(TenantResource::canEdit($otherTenant))->toBeFalse();
});
});

View File

@ -0,0 +1,59 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Tests for destructive action behavior in UiEnforcement
*
* These tests verify that:
* - Destructive actions are configured with confirmation modal
* - Modal heading/description are set correctly
* - Action only executes after confirmation
*/
describe('Destructive actions require confirmation', function () {
beforeEach(function () {
Queue::fake();
bindFailHardGraphClient();
});
it('mounts sync action for modal confirmation', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// mountAction shows the confirmation modal
// assertActionMounted confirms it was mounted (awaiting confirmation)
Livewire::test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionEnabled('sync')
->mountAction('sync')
->assertActionMounted('sync');
});
it('does not execute destructive action without calling confirm', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// Mount but don't call - verify no side effects
Livewire::test(ListPolicies::class)
->mountAction('sync');
// No job should be dispatched yet
Queue::assertNothingPushed();
});
it('has confirmation modal configured with correct title', function () {
// Verify UiTooltips constants are set correctly
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBe('Are you sure?');
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBe('This action cannot be undone.');
});
});

View File

@ -0,0 +1,92 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Jobs\SyncPoliciesJob;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Tests for US1: Tenant member sees consistent disabled UX
*
* These tests verify that UiEnforcement correctly handles:
* - Members WITH capability action enabled, can execute
* - Members WITHOUT capability action visible but disabled with tooltip, cannot execute
*
* Note: In Filament v5, disabled actions don't throw 403 - they silently fail.
* The server-side guard is a defense-in-depth measure that only triggers if
* somehow the disabled check is bypassed.
*/
describe('US1: Member without capability sees disabled action + tooltip', function () {
beforeEach(function () {
Queue::fake();
});
it('shows sync action as visible but disabled for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionDisabled('sync');
Queue::assertNothingPushed();
});
it('does not execute sync action for readonly members (silently blocked by Filament)', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// When a disabled action is called, Filament blocks it silently (200 response, no execution)
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertSuccessful();
// The action should NOT have executed
Queue::assertNothingPushed();
});
});
describe('US1: Member with capability sees enabled action + can execute', function () {
beforeEach(function () {
Queue::fake();
});
it('shows sync action as enabled for owner members', function () {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionEnabled('sync');
});
it('allows owner members to execute sync action successfully', function () {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
});
});

View File

@ -0,0 +1,152 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Tests for US2: Non-members cannot infer tenant resources
*
* These tests verify that UiEnforcement correctly handles:
* - Non-members action hidden in UI (prevents discovery)
* - Non-members action blocked from execution (no side effects)
* - Membership revoked mid-session still enforces protection
*
* Note on 404 behavior:
* In Filament v5, hidden actions are treated as disabled and return 200 (no execution)
* rather than 404. This is because Filament's action system doesn't support custom
* HTTP status codes for blocked actions. The security guarantee is:
* - Non-members cannot discover actions (hidden in UI)
* - Non-members cannot execute actions (blocked by Filament's isHidden check)
* - No side effects occur (jobs not pushed, data not modified)
*
* True 404 enforcement happens at the page/routing level via tenant middleware.
*/
describe('US2: Non-member sees action hidden in UI', function () {
beforeEach(function () {
Queue::fake();
});
it('hides sync action for users who are not members of the tenant', function () {
// Create user without membership to the tenant
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
// No membership created
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionHidden('sync');
Queue::assertNothingPushed();
});
it('hides sync action for authenticated users accessing wrong tenant', function () {
// User is member of tenantA but accessing tenantB
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$tenantB = Tenant::factory()->create();
// User has no membership to tenantB
$this->actingAs($user);
$tenantB->makeCurrent();
Filament::setTenant($tenantB, true);
Livewire::test(ListPolicies::class)
->assertActionHidden('sync');
Queue::assertNothingPushed();
});
});
describe('US2: Non-member action execution is blocked', function () {
beforeEach(function () {
Queue::fake();
});
it('blocks action execution for non-members (no side effects)', function () {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
// No membership
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// Hidden actions are treated as disabled by Filament
// The action call returns 200 but no execution occurs
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertSuccessful();
// Verify no side effects
Queue::assertNothingPushed();
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
});
describe('US2: Membership revoked mid-session still enforces protection', function () {
beforeEach(function () {
Queue::fake();
});
it('blocks action execution when membership is revoked between page load and action click', function () {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// Start the test - action should be visible for member
$component = Livewire::test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionEnabled('sync');
// Simulate membership revocation mid-session
$user->tenants()->detach($tenant->getKey());
// Clear capability cache to ensure fresh check
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
// Now try to execute - action is now hidden (via fresh isVisible evaluation)
// Filament blocks execution (returns 200 but no side effects)
$component
->mountAction('sync')
->callMountedAction()
->assertSuccessful();
// Verify no side effects
Queue::assertNothingPushed();
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
it('hides action in UI after membership revocation on re-render', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
// Initial state - action visible
Livewire::test(ListPolicies::class)
->assertActionVisible('sync');
// Revoke membership
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
// New component instance (simulates page refresh)
Livewire::test(ListPolicies::class)
->assertActionHidden('sync');
Queue::assertNothingPushed();
});
});

View File

@ -25,7 +25,7 @@
Livewire::test(ListInventoryItems::class) Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
->assertStatus(403); ->assertSuccessful();
Queue::assertNothingPushed(); Queue::assertNothingPushed();

View File

@ -0,0 +1,84 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\TenantAccessContext;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
describe('TenantAccessContext', function () {
it('correctly identifies non-member as deny-as-not-found', function () {
$context = new TenantAccessContext(
user: User::factory()->make(),
tenant: Tenant::factory()->make(),
isMember: false,
hasCapability: false,
);
expect($context->shouldDenyAsNotFound())->toBeTrue();
expect($context->shouldDenyAsForbidden())->toBeFalse();
expect($context->isAuthorized())->toBeFalse();
});
it('correctly identifies member without capability as forbidden', function () {
$context = new TenantAccessContext(
user: User::factory()->make(),
tenant: Tenant::factory()->make(),
isMember: true,
hasCapability: false,
);
expect($context->shouldDenyAsNotFound())->toBeFalse();
expect($context->shouldDenyAsForbidden())->toBeTrue();
expect($context->isAuthorized())->toBeFalse();
});
it('correctly identifies authorized member', function () {
$context = new TenantAccessContext(
user: User::factory()->make(),
tenant: Tenant::factory()->make(),
isMember: true,
hasCapability: true,
);
expect($context->shouldDenyAsNotFound())->toBeFalse();
expect($context->shouldDenyAsForbidden())->toBeFalse();
expect($context->isAuthorized())->toBeTrue();
});
});
describe('UiTooltips', function () {
it('has non-empty insufficient permission message', function () {
expect(UiTooltips::INSUFFICIENT_PERMISSION)->toBeString();
expect(UiTooltips::INSUFFICIENT_PERMISSION)->not->toBeEmpty();
});
it('has non-empty destructive confirmation messages', function () {
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBeString();
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->not->toBeEmpty();
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBeString();
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->not->toBeEmpty();
});
});
describe('UiEnforcement', function () {
it('throws when unknown capability is passed', function () {
$action = \Filament\Actions\Action::make('test')
->action(fn () => null);
expect(fn () => UiEnforcement::forAction($action)
->requireCapability('unknown.capability')
)->toThrow(\InvalidArgumentException::class, 'Unknown capability');
});
it('accepts known capabilities from registry', function () {
$action = \Filament\Actions\Action::make('test')
->action(fn () => null);
$enforcement = UiEnforcement::forAction($action)
->requireCapability(Capabilities::PROVIDER_MANAGE);
expect($enforcement)->toBeInstanceOf(UiEnforcement::class);
});
});