066-rbac-ui-enforcement-helper (#81)
Kontext / Ziel Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk). Links zur Spec: spec.md plan.md quickstart.md Was ist drin Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions) Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext) Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns) CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**: verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort) RBAC-UX Semantik (konsequent & testbar) Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards. Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects). Member mit Capability: Action enabled und ausführbar. Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)). Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes. Sicherheit / Scope Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP). Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership). Kein Asset-Setup erforderlich; keine neuen Filament Assets. Compliance Notes (Repo-Regeln) Filament v5 / Livewire v4.0+ kompatibel. Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert). Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR. Tests / Qualität Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard. Guard-Test: “No ad-hoc Filament auth patterns”. Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped. Checklist: requirements.md vollständig (16/16). Review-Fokus API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply() Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #81
This commit is contained in:
parent
cfbc74c035
commit
6a86c5901a
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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.';
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\SupportedPolicyTypesRule;
|
use App\Rules\SupportedPolicyTypesRule;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -23,6 +24,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 BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
@ -50,7 +52,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -65,19 +66,32 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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();
|
||||||
|
|
||||||
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)) {
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,32 +106,64 @@ public static function canCreate(): bool
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$user = auth()->user();
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$user = auth()->user();
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$user = auth()->user();
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$user = auth()->user();
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -315,21 +361,22 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('runNow')
|
Action::make('runNow')
|
||||||
->label('Run now')
|
->label('Run now')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
Notification::make()
|
||||||
|
->title('No tenant selected')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
@ -445,22 +492,26 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('retry')
|
Action::make('retry')
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
Notification::make()
|
||||||
|
->title('No tenant selected')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
@ -576,40 +627,40 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(function (): bool {
|
)
|
||||||
$tenant = Tenant::current();
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
|
->apply(),
|
||||||
return $tenant instanceof Tenant
|
UiEnforcement::forAction(
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}),
|
|
||||||
DeleteAction::make()
|
DeleteAction::make()
|
||||||
->visible(function (): bool {
|
)
|
||||||
$tenant = Tenant::current();
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
|
->apply(),
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}),
|
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_run_now')
|
BulkAction::make('bulk_run_now')
|
||||||
->label('Run now')
|
->label('Run now')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
Notification::make()
|
||||||
|
->title('No tenant selected')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
if ($records->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -731,22 +782,26 @@ public static function table(Table $table): Table
|
|||||||
if (count($createdRunIds) > 0) {
|
if (count($createdRunIds) > 0) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_retry')
|
BulkAction::make('bulk_retry')
|
||||||
->label('Retry')
|
->label('Retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
Notification::make()
|
||||||
|
->title('No tenant selected')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
if ($records->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -868,14 +923,15 @@ public static function table(Table $table): Table
|
|||||||
if (count($createdRunIds) > 0) {
|
if (count($createdRunIds) > 0) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
DeleteBulkAction::make('bulk_delete')
|
DeleteBulkAction::make('bulk_delete')
|
||||||
->visible(function (): bool {
|
)
|
||||||
$tenant = Tenant::current();
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||||
|
->apply(),
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,18 +102,15 @@ 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([
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
->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) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
$record->items()->withTrashed()->restore();
|
$record->items()->withTrashed()->restore();
|
||||||
@ -121,19 +130,20 @@ public static function table(Table $table): Table
|
|||||||
->title('Backup set restored')
|
->title('Backup set restored')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
->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) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
@ -152,19 +162,20 @@ public static function table(Table $table): Table
|
|||||||
->title('Backup set archived')
|
->title('Backup set archived')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
->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) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
|
||||||
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -194,18 +205,21 @@ public static function table(Table $table): Table
|
|||||||
->title('Backup set permanently deleted')
|
->title('Backup set permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Archive Backup Sets')
|
->label('Archive Backup Sets')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -240,8 +254,6 @@ public static function table(Table $table): Table
|
|||||||
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 */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -282,14 +294,16 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_restore')
|
BulkAction::make('bulk_restore')
|
||||||
->label('Restore Backup Sets')
|
->label('Restore Backup Sets')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -310,8 +324,6 @@ public static function table(Table $table): Table
|
|||||||
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 */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -352,14 +364,16 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_force_delete')
|
BulkAction::make('bulk_force_delete')
|
||||||
->label('Force Delete Backup Sets')
|
->label('Force Delete Backup Sets')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -395,8 +409,6 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -437,6 +449,9 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,81 +29,19 @@ 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')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to sync groups.';
|
|
||||||
})
|
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort(403);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
@ -182,7 +120,11 @@ protected function getHeaderActions(): array
|
|||||||
])
|
])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to sync groups.')
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,10 @@
|
|||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\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,48 +22,19 @@ class ListEntraGroupSyncRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('sync_groups')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort(403);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
$existing = EntraGroupSyncRun::query()
|
||||||
@ -106,7 +78,11 @@ protected function getHeaderActions(): array
|
|||||||
'run_id' => (int) $run->getKey(),
|
'run_id' => (int) $run->getKey(),
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
]));
|
]));
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,37 +360,20 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('acknowledge_selected')
|
BulkAction::make('acknowledge_selected')
|
||||||
->label('Acknowledge selected')
|
->label('Acknowledge selected')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->authorize(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
|
||||||
|
|
||||||
return $user->can('update', $probe);
|
|
||||||
})
|
|
||||||
->authorizeIndividualRecords('update')
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (Collection $records): void {
|
->action(function (Collection $records): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$firstRecord = $records->first();
|
|
||||||
if ($firstRecord instanceof Finding) {
|
|
||||||
Gate::authorize('update', $firstRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgedCount = 0;
|
$acknowledgedCount = 0;
|
||||||
$skippedCount = 0;
|
$skippedCount = 0;
|
||||||
|
|
||||||
@ -412,6 +412,10 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,23 +21,12 @@ class ListFindings extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('acknowledge_all_matching')
|
Actions\Action::make('acknowledge_all_matching')
|
||||||
->label('Acknowledge all matching')
|
->label('Acknowledge all matching')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->authorize(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
|
||||||
|
|
||||||
return $user->can('update', $probe);
|
|
||||||
})
|
|
||||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||||
->modalDescription(function (): string {
|
->modalDescription(function (): string {
|
||||||
$count = $this->getAllMatchingCount();
|
$count = $this->getAllMatchingCount();
|
||||||
@ -62,13 +51,6 @@ protected function getHeaderActions(): array
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $this->buildAllMatchingQuery();
|
$query = $this->buildAllMatchingQuery();
|
||||||
$count = (clone $query)->count();
|
$count = (clone $query)->count();
|
||||||
|
|
||||||
@ -82,15 +64,10 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$firstRecord = (clone $query)->first();
|
|
||||||
if ($firstRecord instanceof Finding) {
|
|
||||||
Gate::authorize('update', $firstRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $query->update([
|
$updated = $query->update([
|
||||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
'acknowledged_at' => now(),
|
'acknowledged_at' => now(),
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
'acknowledged_by_user_id' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->deselectAllTableRecords();
|
$this->deselectAllTableRecords();
|
||||||
@ -101,21 +78,26 @@ protected function getHeaderActions(): array
|
|||||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,6 +41,7 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('run_inventory_sync')
|
Action::make('run_inventory_sync')
|
||||||
->label('Run Inventory Sync')
|
->label('Run Inventory Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -118,51 +120,12 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to start inventory sync.';
|
|
||||||
})
|
|
||||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort(404);
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||||
@ -172,7 +135,7 @@ protected function getHeaderActions(): array
|
|||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
abort(403, 'Not allowed');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||||
@ -277,7 +240,12 @@ protected function getHeaderActions(): array
|
|||||||
->send();
|
->send();
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
use App\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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,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 BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -41,7 +42,6 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
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;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyResource extends Resource
|
class PolicyResource extends Resource
|
||||||
@ -363,19 +363,14 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('ignore')
|
Actions\Action::make('ignore')
|
||||||
->label('Ignore')
|
->label('Ignore')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
->action(function (Policy $record): void {
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (Policy $record, HasTable $livewire) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->ignore();
|
$record->ignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -383,19 +378,20 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to ignore policies.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
->action(function (Policy $record): void {
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (Policy $record) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->unignore();
|
$record->unignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -403,29 +399,19 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to restore policies.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('sync')
|
Actions\Action::make('sync')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(function (Policy $record): bool {
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
if ($record->ignored_at !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (Policy $record, HasTable $livewire): void {
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -438,10 +424,6 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRun(
|
||||||
@ -479,28 +461,34 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label('Backup Name')
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Policy $record, array $data) {
|
->action(function (Policy $record, array $data): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$ids = [(int) $record->getKey()];
|
$ids = [(int) $record->getKey()];
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -543,10 +531,16 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Ignore Policies')
|
->label('Ignore Policies')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
@ -558,16 +552,6 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $value === 'ignored';
|
return $value === 'ignored';
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
return [
|
return [
|
||||||
@ -583,18 +567,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records): void {
|
||||||
$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 (! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -641,7 +623,11 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_restore')
|
BulkAction::make('bulk_restore')
|
||||||
->label('Restore Policies')
|
->label('Restore Policies')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
@ -653,28 +639,20 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! in_array($value, [null, 'ignored'], true);
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, HasTable $livewire) {
|
|
||||||
$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) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -739,28 +717,17 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_sync')
|
BulkAction::make('bulk_sync')
|
||||||
->label('Sync Policies')
|
->label('Sync Policies')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
@ -779,10 +746,6 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $records
|
$ids = $records
|
||||||
->pluck('id')
|
->pluck('id')
|
||||||
->map(static fn ($id): int => (int) $id)
|
->map(static fn ($id): int => (int) $id)
|
||||||
@ -829,38 +792,34 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_export')
|
BulkAction::make('bulk_export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label('Backup Name')
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records, array $data): void {
|
||||||
$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) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -926,6 +885,9 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,61 +23,17 @@ class ListPolicies extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('sync')
|
Actions\Action::make('sync')
|
||||||
->label('Sync from Intune')
|
->label('Sync from Intune')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
|
||||||
&& $user->canAccessTenant($tenant);
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return ! ($user instanceof User
|
|
||||||
&& $tenant instanceof Tenant
|
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! ($user instanceof User && $tenant instanceof Tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to sync policies.';
|
|
||||||
})
|
|
||||||
->action(function (self $livewire): void {
|
->action(function (self $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort(403);
|
abort(404);
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestedTypes = array_map(
|
$requestedTypes = array_map(
|
||||||
@ -125,7 +81,12 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to sync policies.')
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,20 @@
|
|||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\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,29 +26,10 @@ class VersionsRelationManager extends RelationManager
|
|||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
])
|
|
||||||
->defaultSort('version_number', 'desc')
|
|
||||||
->filters([])
|
|
||||||
->headerActions([])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('restore_to_intune')
|
|
||||||
->label('Restore to Intune')
|
->label('Restore to Intune')
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->color('danger')
|
->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()
|
->requiresConfirmation()
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||||
@ -56,8 +40,16 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Missing tenant or user context.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($record->tenant_id !== $tenant->id) {
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -73,8 +65,8 @@ public function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
version: $record,
|
version: $record,
|
||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
actorEmail: auth()->user()?->email,
|
actorEmail: $user->email,
|
||||||
actorName: auth()->user()?->name,
|
actorName: $user->name,
|
||||||
);
|
);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -92,7 +84,74 @@ public function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
|
|
||||||
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($restoreToIntune)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$restoreToIntune
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||||
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('version_number', 'desc')
|
||||||
|
->filters([])
|
||||||
|
->headerActions([])
|
||||||
|
->actions([
|
||||||
|
$restoreToIntune,
|
||||||
Actions\ViewAction::make()
|
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),
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
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\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
@ -21,6 +22,7 @@
|
|||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
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 Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -39,7 +41,6 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyVersionResource extends Resource
|
class PolicyVersionResource extends Resource
|
||||||
@ -183,6 +184,294 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||||
|
->label('Prune Versions')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->form(function (Collection $records) {
|
||||||
|
$fields = [
|
||||||
|
Forms\Components\TextInput::make('retention_days')
|
||||||
|
->label('Retention Days')
|
||||||
|
->helperText('Versions captured within the last N days will be skipped.')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->default(90)
|
||||||
|
->minValue(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($records->count() >= 20) {
|
||||||
|
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.prune',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||||
|
BulkPolicyVersionPruneJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
retentionDays: $retentionDays,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
'retention_days' => $retentionDays,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version prune queued')
|
||||||
|
->body("Queued prune for {$count} policy versions.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($initiator);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||||
|
->label('Restore Versions')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||||
|
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.restore',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkPolicyVersionRestoreJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||||
|
->label('Force Delete Versions')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return ! $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||||
|
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy_version.force_delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
|
BulkPolicyVersionForceDeleteJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
policyVersionIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
|
'policy_version_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version force delete queued')
|
||||||
|
->body("Queued force delete for {$count} policy versions.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($initiator);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion();
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
|
||||||
@ -208,14 +497,64 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\Action::make('restore_via_wizard')
|
(function (): Actions\Action {
|
||||||
|
$action = Actions\Action::make('restore_via_wizard')
|
||||||
->label('Restore via Wizard')
|
->label('Restore via Wizard')
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only'
|
->requiresConfirmation()
|
||||||
|| ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||||
|
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant);
|
||||||
|
})
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) {
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
return 'You do not have permission to create restore runs.';
|
return 'You do not have permission to create restore runs.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,14 +564,34 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
|
||||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
|
||||||
->action(function (PolicyVersion $record) {
|
->action(function (PolicyVersion $record) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore disabled for metadata-only snapshot')
|
||||||
|
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -312,41 +671,24 @@ public static function table(Table $table): Table
|
|||||||
'scope_mode' => 'selected',
|
'scope_mode' => 'selected',
|
||||||
'backup_item_ids' => [$backupItem->id],
|
'backup_item_ids' => [$backupItem->id],
|
||||||
]));
|
]));
|
||||||
}),
|
});
|
||||||
Actions\Action::make('archive')
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
|
(function (): Actions\Action {
|
||||||
|
$action = Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
||||||
->disabled(function (PolicyVersion $record): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
@ -365,41 +707,30 @@ public static function table(Table $table): Table
|
|||||||
->title('Policy version archived')
|
->title('Policy version archived')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
});
|
||||||
Actions\Action::make('forceDelete')
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
|
(function (): Actions\Action {
|
||||||
|
$action = Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
->disabled(function (PolicyVersion $record): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -418,42 +749,31 @@ public static function table(Table $table): Table
|
|||||||
->title('Policy version permanently deleted')
|
->title('Policy version permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
});
|
||||||
|
|
||||||
Actions\Action::make('restore')
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
(function (): Actions\Action {
|
||||||
|
$action = Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
->disabled(function (PolicyVersion $record): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
@ -472,350 +792,23 @@ public static function table(Table $table): Table
|
|||||||
->title('Policy version restored')
|
->title('Policy version restored')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($action)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage policy versions.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
})(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('bulk_prune_versions')
|
$bulkPruneVersions,
|
||||||
->label('Prune Versions')
|
$bulkRestoreVersions,
|
||||||
->icon('heroicon-o-trash')
|
$bulkForceDeleteVersions,
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->form(function (Collection $records) {
|
|
||||||
$fields = [
|
|
||||||
Forms\Components\TextInput::make('retention_days')
|
|
||||||
->label('Retention Days')
|
|
||||||
->helperText('Versions captured within the last N days will be skipped.')
|
|
||||||
->numeric()
|
|
||||||
->required()
|
|
||||||
->default(90)
|
|
||||||
->minValue(1),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($records->count() >= 20) {
|
|
||||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
|
||||||
->label('Type DELETE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fields;
|
|
||||||
})
|
|
||||||
->action(function (Collection $records, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy_version.prune',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
|
||||||
BulkPolicyVersionPruneJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
|
||||||
policyVersionIds: $ids,
|
|
||||||
retentionDays: $retentionDays,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
extraContext: [
|
|
||||||
'policy_version_count' => $count,
|
|
||||||
'retention_days' => $retentionDays,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($initiator instanceof User) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version prune queued')
|
|
||||||
->body("Queued prune for {$count} policy versions.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($initiator);
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
|
|
||||||
BulkAction::make('bulk_restore_versions')
|
|
||||||
->label('Restore Versions')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->color('success')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
|
||||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
|
||||||
->action(function (Collection $records) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy_version.restore',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
|
||||||
BulkPolicyVersionRestoreJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
|
||||||
policyVersionIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
extraContext: [
|
|
||||||
'policy_version_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
|
|
||||||
BulkAction::make('bulk_force_delete_versions')
|
|
||||||
->label('Force Delete Versions')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to manage policy versions.';
|
|
||||||
})
|
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
|
||||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
|
||||||
->form([
|
|
||||||
Forms\Components\TextInput::make('confirmation')
|
|
||||||
->label('Type DELETE to confirm')
|
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(function (Collection $records, array $data) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
$count = $records->count();
|
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless($user instanceof User, 403);
|
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy_version.force_delete',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
|
||||||
BulkPolicyVersionForceDeleteJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
|
||||||
policyVersionIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
extraContext: [
|
|
||||||
'policy_version_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($initiator instanceof User) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version force delete queued')
|
|
||||||
->body("Queued force delete for {$count} policy versions.")
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->iconColor('warning')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->duration(8000)
|
|
||||||
->sendToDatabase($initiator);
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
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\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -29,7 +31,6 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'display_name';
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
|
protected static function hasTenantCapability(string $capability): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, $capability);
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema
|
|||||||
TextInput::make('display_name')
|
TextInput::make('display_name')
|
||||||
->label('Display name')
|
->label('Display name')
|
||||||
->required()
|
->required()
|
||||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra tenant ID')
|
->label('Entra tenant ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
Toggle::make('is_default')
|
Toggle::make('is_default')
|
||||||
->label('Default connection')
|
->label('Default connection')
|
||||||
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
@ -146,55 +163,26 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\EditAction::make(),
|
UiEnforcement::forAction(
|
||||||
|
Actions\EditAction::make()
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('check_connection')
|
Actions\Action::make('check_connection')
|
||||||
->label('Check connection')
|
->label('Check connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -252,55 +240,26 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('inventory_sync')
|
Actions\Action::make('inventory_sync')
|
||||||
->label('Inventory sync')
|
->label('Inventory sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -358,55 +317,26 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('compliance_snapshot')
|
Actions\Action::make('compliance_snapshot')
|
||||||
->label('Compliance snapshot')
|
->label('Compliance snapshot')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
abort_unless($user instanceof User, 403);
|
return;
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -464,19 +394,24 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('set_default')
|
Actions\Action::make('set_default')
|
||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
&& $record->status !== 'disabled'
|
|
||||||
&& ! $record->is_default)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$record->makeDefault();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -506,14 +441,18 @@ public static function table(Table $table): Table
|
|||||||
->title('Default connection updated')
|
->title('Default connection updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('update_credentials')
|
Actions\Action::make('update_credentials')
|
||||||
->label('Update credentials')
|
->label('Update credentials')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
@ -528,7 +467,9 @@ public static function table(Table $table): Table
|
|||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -562,18 +503,23 @@ public static function table(Table $table): Table
|
|||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('enable_connection')
|
Actions\Action::make('enable_connection')
|
||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
&& $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -626,19 +572,25 @@ public static function table(Table $table): Table
|
|||||||
->title('Provider connection enabled')
|
->title('Provider connection enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('disable_connection')
|
Actions\Action::make('disable_connection')
|
||||||
->label('Disable connection')
|
->label('Disable connection')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
&& $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
@ -673,7 +625,11 @@ public static function table(Table $table): Table
|
|||||||
->title('Provider connection disabled')
|
->title('Provider connection disabled')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -10,18 +10,19 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
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\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class EditProviderConnection extends EditRecord
|
class EditProviderConnection extends EditRecord
|
||||||
{
|
{
|
||||||
@ -115,12 +116,12 @@ protected function getHeaderActions(): array
|
|||||||
->visible(false),
|
->visible(false),
|
||||||
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('view_last_check_run')
|
Action::make('view_last_check_run')
|
||||||
->label('View last check run')
|
->label('View last check run')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
|
|
||||||
&& OperationRun::query()
|
&& OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'provider.connection.check')
|
->where('type', 'provider.connection.check')
|
||||||
@ -145,8 +146,14 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return OperationRunLinks::view($run, $tenant);
|
return OperationRunLinks::view($run, $tenant);
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
|
->tooltip('You do not have permission to view provider connections.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('check_connection')
|
Action::make('check_connection')
|
||||||
->label('Check connection')
|
->label('Check connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
@ -160,36 +167,22 @@ protected function getHeaderActions(): array
|
|||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless($user instanceof User, 403);
|
abort(404);
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -247,14 +240,20 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->tooltip('You do not have permission to run provider operations.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('update_credentials')
|
Action::make('update_credentials')
|
||||||
->label('Update credentials')
|
->label('Update credentials')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant))
|
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
@ -269,7 +268,9 @@ protected function getHeaderActions(): array
|
|||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -303,14 +304,19 @@ protected function getHeaderActions(): array
|
|||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage provider connections.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('set_default')
|
Action::make('set_default')
|
||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
|
||||||
&& $record->status !== 'disabled'
|
&& $record->status !== 'disabled'
|
||||||
&& ! $record->is_default
|
&& ! $record->is_default
|
||||||
&& ProviderConnection::query()
|
&& ProviderConnection::query()
|
||||||
@ -320,7 +326,9 @@ protected function getHeaderActions(): array
|
|||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$record->makeDefault();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -350,8 +358,14 @@ protected function getHeaderActions(): array
|
|||||||
->title('Default connection updated')
|
->title('Default connection updated')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage provider connections.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('inventory_sync')
|
Action::make('inventory_sync')
|
||||||
->label('Inventory sync')
|
->label('Inventory sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -365,36 +379,22 @@ protected function getHeaderActions(): array
|
|||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless($user instanceof User, 403);
|
abort(404);
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -452,8 +452,14 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->tooltip('You do not have permission to run provider operations.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('compliance_snapshot')
|
Action::make('compliance_snapshot')
|
||||||
->label('Compliance snapshot')
|
->label('Compliance snapshot')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
@ -467,36 +473,22 @@ protected function getHeaderActions(): array
|
|||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->disabled(function (): bool {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
|
||||||
})
|
|
||||||
->tooltip(function (): ?string {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
|
||||||
? null
|
|
||||||
: 'You do not have permission to run provider operations.';
|
|
||||||
})
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant, 404);
|
if (! $tenant instanceof Tenant) {
|
||||||
abort_unless($user instanceof User, 403);
|
abort(404);
|
||||||
abort_unless($user->canAccessTenant($tenant), 404);
|
}
|
||||||
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -554,19 +546,25 @@ protected function getHeaderActions(): array
|
|||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->tooltip('You do not have permission to run provider operations.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('enable_connection')
|
Action::make('enable_connection')
|
||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
|
||||||
&& $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -619,20 +617,26 @@ protected function getHeaderActions(): array
|
|||||||
->title('Provider connection enabled')
|
->title('Provider connection enabled')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage provider connections.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
Action::make('disable_connection')
|
Action::make('disable_connection')
|
||||||
->label('Disable connection')
|
->label('Disable connection')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
|
||||||
&& $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
@ -667,7 +671,12 @@ protected function getHeaderActions(): array
|
|||||||
->title('Provider connection disabled')
|
->title('Provider connection disabled')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage provider connections.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
@ -679,7 +688,17 @@ protected function getFormActions(): array
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) {
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return [
|
||||||
|
$this->getCancelFormAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||||
return parent::getFormActions();
|
return parent::getFormActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -692,7 +711,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
return parent::handleRecordUpdate($record, $data);
|
return parent::handleRecordUpdate($record, $data);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\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 [
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\CreateAction::make()
|
Actions\CreateAction::make()
|
||||||
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
|
->authorize(fn (): bool => true)
|
||||||
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())
|
)
|
||||||
? null
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
: 'You do not have permission to create provider connections.'),
|
->tooltip('You do not have permission to create provider connections.')
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\SkipOrUuidRule;
|
use App\Rules\SkipOrUuidRule;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreDiffGenerator;
|
use App\Services\Intune\RestoreDiffGenerator;
|
||||||
@ -27,6 +28,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 App\Support\RestoreRunIdempotency;
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -50,7 +52,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -65,8 +66,18 @@ class RestoreRunResource 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_MANAGE, $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_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -748,6 +759,7 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('rerun')
|
Actions\Action::make('rerun')
|
||||||
->label('Rerun')
|
->label('Rerun')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -761,18 +773,12 @@ public static function table(Table $table): Table
|
|||||||
&& $backupSet !== null
|
&& $backupSet !== null
|
||||||
&& ! $backupSet->trashed();
|
&& ! $backupSet->trashed();
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (
|
->action(function (
|
||||||
RestoreRun $record,
|
RestoreRun $record,
|
||||||
RestoreService $restoreService,
|
RestoreService $restoreService,
|
||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
HasTable $livewire
|
HasTable $livewire
|
||||||
) {
|
) {
|
||||||
$currentTenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
@ -933,19 +939,19 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast('restore.execute')
|
OperationUxPresenter::queuedToast('restore.execute')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -964,19 +970,19 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
if (! $record->isDeletable()) {
|
if (! $record->isDeletable()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run cannot be archived')
|
->title('Restore run cannot be archived')
|
||||||
@ -1005,19 +1011,19 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
@ -1036,17 +1042,21 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
fn () => Tenant::current(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Archive Restore Runs')
|
->label('Archive Restore Runs')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1080,8 +1090,6 @@ public static function table(Table $table): Table
|
|||||||
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 */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1122,14 +1130,16 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_restore')
|
BulkAction::make('bulk_restore')
|
||||||
->label('Restore Restore Runs')
|
->label('Restore Restore Runs')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1150,8 +1160,6 @@ public static function table(Table $table): Table
|
|||||||
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 */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1203,14 +1211,16 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_force_delete')
|
BulkAction::make('bulk_force_delete')
|
||||||
->label('Force Delete Restore Runs')
|
->label('Force Delete Restore Runs')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1240,8 +1250,6 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1293,6 +1301,9 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -1491,10 +1502,23 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
|||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
{
|
{
|
||||||
/** @var Tenant $tenant */
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var BackupSet $backupSet */
|
/** @var BackupSet $backupSet */
|
||||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
->disabled(function (): bool {
|
->action(function (Tenant $record): void {
|
||||||
$tenant = $this->record;
|
$record->delete();
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
|
|
||||||
})
|
})
|
||||||
->tooltip(function (): ?string {
|
)
|
||||||
$tenant = $this->record;
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
$user = auth()->user();
|
->tooltip('You do not have permission to archive tenants.')
|
||||||
|
->preserveVisibility()
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
->destructive()
|
||||||
return null;
|
->apply(),
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,18 +40,10 @@ 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(
|
||||||
|
Action::make('add_member')
|
||||||
->label(__('Add member'))
|
->label(__('Add member'))
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
@ -80,10 +72,6 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$member = User::query()->find((int) $data['user_id']);
|
$member = User::query()->find((int) $data['user_id']);
|
||||||
if (! $member) {
|
if (! $member) {
|
||||||
Notification::make()->title(__('User not found'))->danger()->send();
|
Notification::make()->title(__('User not found'))->danger()->send();
|
||||||
@ -112,21 +100,18 @@ public function table(Table $table): Table
|
|||||||
Notification::make()->title(__('Member added'))->success()->send();
|
Notification::make()->title(__('Member added'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('change_role')
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('change_role')
|
||||||
->label(__('Change role'))
|
->label(__('Change role'))
|
||||||
->icon('heroicon-o-pencil')
|
->icon('heroicon-o-pencil')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label(__('Role'))
|
->label(__('Role'))
|
||||||
@ -150,10 +135,6 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$manager->changeRole(
|
$manager->changeRole(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -174,20 +155,18 @@ public function table(Table $table): Table
|
|||||||
Notification::make()->title(__('Role updated'))->success()->send();
|
Notification::make()->title(__('Role updated'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('remove')
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
->label(__('Remove'))
|
->label(__('Remove'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
|
||||||
})
|
|
||||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
@ -200,10 +179,6 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$manager->removeMember($tenant, $actor, $record);
|
$manager->removeMember($tenant, $actor, $record);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
@ -219,6 +194,12 @@ public function table(Table $table): Table
|
|||||||
Notification::make()->title(__('Member removed'))->success()->send();
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
48
app/Support/Rbac/TenantAccessContext.php
Normal file
48
app/Support/Rbac/TenantAccessContext.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the access context for a tenant-scoped UI action.
|
||||||
|
*
|
||||||
|
* Captures the current user, tenant, membership status, and capability check result
|
||||||
|
* for use by the UiEnforcement helper.
|
||||||
|
*/
|
||||||
|
final readonly class TenantAccessContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?User $user,
|
||||||
|
public ?Tenant $tenant,
|
||||||
|
public bool $isMember,
|
||||||
|
public bool $hasCapability,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-members should receive 404 (deny-as-not-found).
|
||||||
|
*/
|
||||||
|
public function shouldDenyAsNotFound(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Members without capability should receive 403 (forbidden).
|
||||||
|
*/
|
||||||
|
public function shouldDenyAsForbidden(): bool
|
||||||
|
{
|
||||||
|
return $this->isMember && ! $this->hasCapability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User is authorized to perform the action.
|
||||||
|
*/
|
||||||
|
public function isAuthorized(): bool
|
||||||
|
{
|
||||||
|
return $this->isMember && $this->hasCapability;
|
||||||
|
}
|
||||||
|
}
|
||||||
414
app/Support/Rbac/UiEnforcement.php
Normal file
414
app/Support/Rbac/UiEnforcement.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Closure;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use ReflectionObject;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central RBAC UI Enforcement Helper for Filament Actions.
|
||||||
|
*
|
||||||
|
* Enforces constitution RBAC-UX rules:
|
||||||
|
* - Non-member → hidden UI + 404 server-side
|
||||||
|
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||||
|
* - Member with capability → enabled
|
||||||
|
* - Destructive actions → requiresConfirmation()
|
||||||
|
*
|
||||||
|
* @see \App\Support\Rbac\UiTooltips
|
||||||
|
* @see \App\Support\Rbac\TenantAccessContext
|
||||||
|
*/
|
||||||
|
final class UiEnforcement
|
||||||
|
{
|
||||||
|
private Action|BulkAction $action;
|
||||||
|
|
||||||
|
private bool $requireMembership = true;
|
||||||
|
|
||||||
|
private ?string $capability = null;
|
||||||
|
|
||||||
|
private bool $isDestructive = false;
|
||||||
|
|
||||||
|
private ?string $customTooltip = null;
|
||||||
|
|
||||||
|
private Model|Closure|null $record = null;
|
||||||
|
|
||||||
|
private ?Collection $records = null;
|
||||||
|
|
||||||
|
private bool $isBulk = false;
|
||||||
|
|
||||||
|
private bool $preserveExistingVisibility = false;
|
||||||
|
|
||||||
|
private function __construct(Action|BulkAction $action)
|
||||||
|
{
|
||||||
|
$this->action = $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a header/page action.
|
||||||
|
*
|
||||||
|
* @param Action $action The Filament action to wrap
|
||||||
|
*/
|
||||||
|
public static function forAction(Action $action): self
|
||||||
|
{
|
||||||
|
return new self($action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a table row action.
|
||||||
|
*
|
||||||
|
* @param Action $action The Filament action to wrap
|
||||||
|
* @param Model|Closure $record The record or a closure that returns the record
|
||||||
|
*/
|
||||||
|
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||||
|
{
|
||||||
|
$instance = new self($action);
|
||||||
|
$instance->record = $record;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a bulk action with all-or-nothing semantics.
|
||||||
|
*
|
||||||
|
* If any selected record fails the capability check for a member,
|
||||||
|
* the action is disabled entirely.
|
||||||
|
*
|
||||||
|
* @param BulkAction $action The Filament bulk action to wrap
|
||||||
|
*/
|
||||||
|
public static function forBulkAction(BulkAction $action): self
|
||||||
|
{
|
||||||
|
$instance = new self($action);
|
||||||
|
$instance->isBulk = true;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require tenant membership for this action.
|
||||||
|
*
|
||||||
|
* @param bool $require Whether membership is required (default: true)
|
||||||
|
*/
|
||||||
|
public function requireMembership(bool $require = true): self
|
||||||
|
{
|
||||||
|
$this->requireMembership = $require;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a specific capability for this action.
|
||||||
|
*
|
||||||
|
* @param string $capability A capability constant from Capabilities class
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||||
|
*/
|
||||||
|
public function requireCapability(string $capability): self
|
||||||
|
{
|
||||||
|
if (! Capabilities::isKnown($capability)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->capability = $capability;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark this action as destructive (requires confirmation modal).
|
||||||
|
*/
|
||||||
|
public function destructive(): self
|
||||||
|
{
|
||||||
|
$this->isDestructive = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the default tooltip for disabled actions.
|
||||||
|
*
|
||||||
|
* @param string $message Custom tooltip message
|
||||||
|
*/
|
||||||
|
public function tooltip(string $message): self
|
||||||
|
{
|
||||||
|
$this->customTooltip = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve the action's existing visibility logic.
|
||||||
|
*
|
||||||
|
* Use this when the action already has business-logic visibility
|
||||||
|
* (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept.
|
||||||
|
*
|
||||||
|
* UiEnforcement will combine the existing visibility condition with tenant
|
||||||
|
* membership visibility, instead of overwriting it.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function preserveVisibility(): self
|
||||||
|
{
|
||||||
|
$this->preserveExistingVisibility = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all enforcement rules to the action and return it.
|
||||||
|
*
|
||||||
|
* This sets up:
|
||||||
|
* - UI visibility (hidden for non-members)
|
||||||
|
* - UI disabled state + tooltip (for members without capability)
|
||||||
|
* - Destructive confirmation (if marked)
|
||||||
|
* - Server-side guards (404/403)
|
||||||
|
*
|
||||||
|
* @return Action|BulkAction The configured action
|
||||||
|
*/
|
||||||
|
public function apply(): Action|BulkAction
|
||||||
|
{
|
||||||
|
$this->applyVisibility();
|
||||||
|
$this->applyDisabledState();
|
||||||
|
$this->applyDestructiveConfirmation();
|
||||||
|
$this->applyServerSideGuard();
|
||||||
|
|
||||||
|
return $this->action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide action for non-members.
|
||||||
|
*
|
||||||
|
* Skipped if preserveVisibility() was called.
|
||||||
|
*/
|
||||||
|
private function applyVisibility(): void
|
||||||
|
{
|
||||||
|
if (! $this->requireMembership) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingVisibility = $this->preserveExistingVisibility
|
||||||
|
? $this->getExistingVisibilityCondition()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
if (! $context->isMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingVisibility === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve the existing visibility condition from the action.
|
||||||
|
*
|
||||||
|
* Filament stores this as the protected property `$isVisible` (bool|Closure)
|
||||||
|
* on actions via the CanBeHidden concern.
|
||||||
|
*/
|
||||||
|
private function getExistingVisibilityCondition(): bool|Closure|null
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionObject($this->action);
|
||||||
|
if (! $ref->hasProperty('isVisible')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$property = $ref->getProperty('isVisible');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var bool|Closure $value */
|
||||||
|
$value = $property->getValue($this->action);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate an existing bool|Closure visibility condition.
|
||||||
|
*
|
||||||
|
* This is a best-effort evaluator for business visibility closures.
|
||||||
|
* If the closure cannot be evaluated safely, we fail closed (return false).
|
||||||
|
*/
|
||||||
|
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
||||||
|
{
|
||||||
|
if (is_bool($condition)) {
|
||||||
|
return $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionFunction($condition);
|
||||||
|
$parameters = $reflection->getParameters();
|
||||||
|
|
||||||
|
if ($parameters === []) {
|
||||||
|
return (bool) $condition();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $condition($record);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable action for members without capability.
|
||||||
|
*/
|
||||||
|
private function applyDisabledState(): void
|
||||||
|
{
|
||||||
|
if ($this->capability === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
|
||||||
|
$this->action->disabled(function (?Model $record = null) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
// Non-members are hidden, so this only affects members
|
||||||
|
if (! $context->isMember) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $context->hasCapability;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show tooltip when actually disabled
|
||||||
|
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
if ($context->isMember && ! $context->hasCapability) {
|
||||||
|
return $tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add confirmation modal for destructive actions.
|
||||||
|
*/
|
||||||
|
private function applyDestructiveConfirmation(): void
|
||||||
|
{
|
||||||
|
if (! $this->isDestructive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->action->requiresConfirmation();
|
||||||
|
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||||
|
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the action handler with server-side authorization guard.
|
||||||
|
*
|
||||||
|
* This is a defense-in-depth measure. In normal operation, Filament's
|
||||||
|
* isDisabled() check prevents execution. This guard catches edge cases
|
||||||
|
* where the disabled check might be bypassed.
|
||||||
|
*/
|
||||||
|
private function applyServerSideGuard(): void
|
||||||
|
{
|
||||||
|
$this->action->before(function (?Model $record = null): void {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
// Non-member → 404 (deny-as-not-found)
|
||||||
|
if ($context->shouldDenyAsNotFound()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member without capability → 403 (forbidden)
|
||||||
|
if ($context->shouldDenyAsForbidden()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current access context with an optional record.
|
||||||
|
*/
|
||||||
|
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
||||||
|
$tenant = $this->resolveTenantWithRecord($record);
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return new TenantAccessContext(
|
||||||
|
user: null,
|
||||||
|
tenant: null,
|
||||||
|
isMember: false,
|
||||||
|
hasCapability: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$isMember = $resolver->isMember($user, $tenant);
|
||||||
|
|
||||||
|
$hasCapability = true;
|
||||||
|
if ($this->capability !== null && $isMember) {
|
||||||
|
$hasCapability = $resolver->can($user, $tenant, $this->capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TenantAccessContext(
|
||||||
|
user: $user,
|
||||||
|
tenant: $tenant,
|
||||||
|
isMember: $isMember,
|
||||||
|
hasCapability: $hasCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the tenant for this action with an optional record.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. If $record is passed and is a Tenant, use it
|
||||||
|
* 2. If $this->record is set (for forTableAction), resolve it
|
||||||
|
* 3. Fall back to Filament::getTenant()
|
||||||
|
*/
|
||||||
|
private function resolveTenantWithRecord(?Model $record = null): ?Tenant
|
||||||
|
{
|
||||||
|
// If a record is passed directly (from closure parameter), check if it's a Tenant
|
||||||
|
if ($record instanceof Tenant) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a record is set from forTableAction, try to resolve it
|
||||||
|
if ($this->record !== null) {
|
||||||
|
$resolved = $this->record instanceof Closure
|
||||||
|
? ($this->record)()
|
||||||
|
: $this->record;
|
||||||
|
|
||||||
|
if ($resolved instanceof Tenant) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: use Filament's current tenant
|
||||||
|
return Filament::getTenant();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Support/Rbac/UiTooltips.php
Normal file
33
app/Support/Rbac/UiTooltips.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized tooltip and confirmation messages for RBAC UI enforcement.
|
||||||
|
*
|
||||||
|
* These constants provide consistent, non-leaky messaging for:
|
||||||
|
* - Permission denials (members lacking capability)
|
||||||
|
* - Destructive action confirmations
|
||||||
|
*
|
||||||
|
* @see \App\Support\Rbac\UiEnforcement
|
||||||
|
*/
|
||||||
|
final class UiTooltips
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tooltip shown when a member lacks the required capability.
|
||||||
|
* Intentionally vague to avoid leaking permission structure.
|
||||||
|
*/
|
||||||
|
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal heading for destructive action confirmation.
|
||||||
|
*/
|
||||||
|
public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal description for destructive action confirmation.
|
||||||
|
*/
|
||||||
|
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
||||||
|
}
|
||||||
@ -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.
|
||||||
188
specs/066-rbac-ui-enforcement-helper/plan.md
Normal file
188
specs/066-rbac-ui-enforcement-helper/plan.md
Normal 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 3–6 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/... # 3–6 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 + 3–6 migrations).
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
262
specs/066-rbac-ui-enforcement-helper/quickstart.md
Normal file
262
specs/066-rbac-ui-enforcement-helper/quickstart.md
Normal 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.
|
||||||
163
specs/066-rbac-ui-enforcement-helper/spec.md
Normal file
163
specs/066-rbac-ui-enforcement-helper/spec.md
Normal 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 3–6 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 3–6 exemplar action surfaces, reducing duplicate authorization wiring in those areas.
|
||||||
254
specs/066-rbac-ui-enforcement-helper/tasks.md
Normal file
254
specs/066-rbac-ui-enforcement-helper/tasks.md
Normal 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 3–5)**: 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 (T012–T014, T019–T021, T026–T027) 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 (T001–T003)
|
||||||
|
2. Complete Phase 2: Foundational (T004–T011)
|
||||||
|
3. Complete Phase 3: User Story 1 (T012–T018)
|
||||||
|
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 1–3 (T001–T018) |
|
||||||
@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
98
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal file
98
tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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 () {
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal file
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal file
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal file
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal file
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal file
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal file
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal file
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal 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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal file
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal file
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal file
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user