diff --git a/.gitignore b/.gitignore index 766ffe9..50eef79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.log .DS_Store .env +.env.* .env.backup .env.production .phpactor.json @@ -21,7 +22,10 @@ coverage/ /public/storage /storage/*.key /storage/pail +/storage/framework +/storage/logs /vendor +/bootstrap/cache Homestead.json Homestead.yaml Thumbs.db diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 14cf3a6..14ca699 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,17 +1,18 @@ @@ -43,58 +44,72 @@ ### Tenant Isolation is Non-negotiable - Every read/write MUST be tenant-scoped. - Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts). - Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected. -- A non-member attempting to access a tenant route MUST be deny-as-not-found (404). +- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as + deny-as-not-found (404). -### RBAC Standard (RBAC-001) +### RBAC & UI Enforcement Standards (RBAC-UX) -RBAC-001 Two Planes +RBAC Context — Planes, Roles, and Auditability - The platform MUST maintain two strictly separated authorization planes: - Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped. - Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped. - Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration. - -RBAC-002 Capabilities-first Authorization -- Feature code MUST NOT check raw roles directly (e.g. string role comparisons). -- Feature code MUST check capabilities via Gates/Policies only. -- A canonical capability registry MUST exist as the single source of truth (e.g. `TenantCapabilities` / `PlatformCapabilities`). -- Role → capability mapping MUST reference only registry entries. - -RBAC-003 Least Privilege Role Semantics -- Tenant roles MUST follow least-privilege semantics: +- Tenant role semantics MUST remain least-privilege: - Readonly: view-only; MUST NOT start operations and MUST NOT mutate data. - Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions. - Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only). - Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only. - -RBAC-004 UI is not Security -- Hiding UI elements is NOT sufficient. -- Every mutation endpoint and action MUST enforce authorization server-side (Policy/Gate). - -RBAC-005 Destructive Actions Gate -- All destructive actions (delete / force delete / irreversible operations) MUST: - - require an explicit confirmation (e.g., `requiresConfirmation()` or equivalent), - - be protected by a Policy/Gate, - - have at least one regression test asserting the action is forbidden for non-authorized roles. - -RBAC-006 Membership Safety Rule - The system MUST prevent removing or demoting the last remaining Owner of a tenant. +- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, and MUST be redacted (no secrets). -RBAC-007 Tenant Isolation -- All tenant-plane queries MUST be tenant-scoped. -- A non-member attempting to access a tenant route MUST be deny-as-not-found (404). +RBAC-UX-001 — Server-side is the source of truth +- UI visibility / disabled state is never a security boundary. +- Every mutating action (create/update/delete/restore/archive/force-delete), every operation start, and every credential/ + config change MUST enforce authorization server-side via `Gate::authorize(...)` or a Policy method. +- Any missing server-side authorization is a P0 security bug. -RBAC-008 Auditing -- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, including: - - membership add / role change / remove - - provider credential rotation / connection disable - - break-glass enter / exit / expire (platform plane) -- `AuditLog` entries MUST be redacted (no secrets/tokens, minimal identity fields). +RBAC-UX-002 — Deny-as-not-found for non-members +- Tenant membership (and plane membership) is an isolation boundary. +- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST + respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources. +- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all + action endpoints (Livewire calls included). -RBAC-009 Testability Gate -- Any new feature that introduces or changes authorization MUST include: - - at least one positive test (authorized user can do it), - - at least one negative test (unauthorized user cannot do it), - - and MUST NOT introduce role-string checks outside the central mapping/registry. +RBAC-UX-003 — Capability denial is 403 (after membership is established) +- Within an established tenant scope, missing permissions are authorization failures. +- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403. +- The UI may render disabled actions, but the server MUST still enforce 403 on execution. + +RBAC-UX-004 — Visible vs disabled UX rule +- For tenant members: actions SHOULD be visible but disabled when capability is missing. +- Disabled actions MUST provide helper text explaining the missing permission. +- For non-members: actions MUST behave as not found (404) and SHOULD NOT leak resource existence. +- Exception: highly sensitive controls (e.g., credential rotation) MAY be hidden even for members without permission. + +RBAC-UX-005 — Destructive confirmation standard +- All destructive-like actions MUST require confirmation. +- Delete/force-delete/archive/restore/remove membership/role downgrade/credential rotation/break-glass enter/exit MUST use + `->requiresConfirmation()` and SHOULD include clear warning text. +- Confirmation is UX only; authorization still MUST be server-side. + +RBAC-UX-006 — Capability registry is canonical +- Capabilities MUST be centrally defined in a single canonical registry (constants/enum). +- Feature code MUST reference capabilities only via the registry (no raw string literals). +- Role → capability mapping MUST reference only registry entries. +- CI MUST fail if unknown/unregistered capabilities are used. + +RBAC-UX-007 — Global search must be tenant-safe +- Global search results MUST be scoped to the current tenant. +- Non-members MUST never learn about resources in other tenants (no results, no hints). +- If a result exists but is not accessible, it MUST be treated as not found (404 semantics). + +RBAC-UX-008 — Regression guards are mandatory +- The repo MUST include RBAC regression tests asserting at least: + - Readonly cannot mutate or start operations. + - Operator can run allowed operations but cannot manage configuration. + - Manager/Owner behave according to the role matrix. +- The repo SHOULD include an automated “no ad-hoc authorization” guard that blocks new status/permission mappings sprinkled + across `app/Filament/**`, pushing patterns into central helpers. ### Operations / Run Observability Standard - Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations. @@ -159,4 +174,4 @@ ### Versioning Policy (SemVer) - **MINOR**: new principle/section or materially expanded guidance. - **MAJOR**: removing/redefining principles in a backward-incompatible way. -**Version**: 1.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-27 +**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index f3bc22c..3a9e510 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -35,7 +35,9 @@ ## Constitution Check - Read/write separation: any writes require preview + confirmation + audit + tests - Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` - Deterministic capabilities: capability derivation is testable (snapshot/golden tests) -- RBAC Standard: two planes (/admin vs /system) remain separated; cross-plane is 404; authorization checks use Gates/Policies + capability registries (no role-string checks) +- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks) +- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text +- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) - Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked - Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index c2bec19..1ae7c69 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -82,12 +82,17 @@ ## Requirements *(mandatory)* (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 Standard):** If this feature introduces or changes authorization behavior, the spec MUST: +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: - state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`), - ensure any cross-plane access is deny-as-not-found (404), -- describe how authorization is enforced server-side (Gates/Policies), -- reference the canonical capability registry (no role-string checks in feature code), -- include at least one positive and one negative authorization test. +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. **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. diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md index afbf92f..63d9c10 100644 --- a/.specify/templates/tasks-template.md +++ b/.specify/templates/tasks-template.md @@ -16,7 +16,12 @@ # Tasks: [FEATURE NAME] without an `OperationRun`. **RBAC**: If this feature introduces or changes authorization, tasks MUST include: - explicit Gate/Policy enforcement for all mutation endpoints/actions, -- capability registry usage (no role-string checks in feature code), +- explicit 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403, +- capability registry usage (no raw capability strings; no role-string checks in feature code), +- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics), +- destructive-like actions use `->requiresConfirmation()` (authorization still server-side), - cross-plane deny-as-not-found (404) checks where applicable, - at least one positive + one negative authorization test. **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 0669388..c225bb5 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -10,6 +10,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Drift\DriftRunSelector; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; @@ -21,7 +22,6 @@ use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Pages\Page; -use Illuminate\Support\Facades\Gate; use UnitEnum; class DriftLanding extends Page @@ -175,7 +175,10 @@ public function mount(): void } } - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) { $this->state = 'blocked'; $this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.'; diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index 06def3d..5940f23 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -4,13 +4,13 @@ use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Support\Auth\Capabilities; use Filament\Forms; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Schemas\Schema; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Gate; class RegisterTenant extends BaseRegisterTenant { @@ -33,8 +33,11 @@ public static function canView(): bool return false; } + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { - if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) { + if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { return true; } } @@ -88,7 +91,9 @@ public function form(Schema $schema): Schema */ protected function handleRegistration(array $data): Model { - abort_unless(static::canView(), 403); + if (! static::canView()) { + abort(403); + } $tenant = Tenant::create($data); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 149464c..6ce9c03 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -11,6 +11,7 @@ use App\Models\Tenant; use App\Models\User; use App\Rules\SupportedPolicyTypesRule; +use App\Services\Auth\CapabilityResolver; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\Intune\AuditLogger; @@ -23,6 +24,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Carbon\CarbonImmutable; use DateTimeZone; @@ -50,7 +52,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -65,19 +66,32 @@ public static function canViewAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } @@ -92,32 +106,64 @@ public static function canCreate(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canEdit(Model $record): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canDelete(Model $record): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function canDeleteAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } public static function form(Schema $schema): Schema @@ -315,329 +361,51 @@ public static function table(Table $table): Table ]) ->actions([ ActionGroup::make([ - Action::make('runNow') - ->label('Run now') - ->icon('heroicon-o-play') - ->color('success') - ->visible(function (): bool { - $tenant = Tenant::current(); + UiEnforcement::forAction( + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - $user = auth()->user(); - $userId = auth()->id(); - $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.run_now', - inputs: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Run already queued') - ->body('This schedule already has a queued or running backup.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); + return; } - } - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Run already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); - - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'success', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'run_now', - ], - ], - ); - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $operationRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - }), - Action::make('retry') - ->label('Retry') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - $user = auth()->user(); - $userId = auth()->id(); - $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.retry', - inputs: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Retry already queued') - ->body('This schedule already has a queued or running retry.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Retry already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); - - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, - status: 'success', - context: [ - 'metadata' => [ - 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'retry', - ], - ], - ); - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $operationRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - }), - EditAction::make() - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), - DeleteAction::make() - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), - ])->icon('heroicon-o-ellipsis-vertical'), - ]) - ->bulkActions([ - BulkActionGroup::make([ - BulkAction::make('bulk_run_now') - ->label('Run now') - ->icon('heroicon-o-play') - ->color('success') - ->visible(function (): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); - - if ($records->isEmpty()) { - return; - } - - $tenant = Tenant::current(); - $userId = auth()->id(); - $user = $userId ? User::query()->find($userId) : null; - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - $bulkRun = null; - - $createdRunIds = []; - - /** @var BackupSchedule $record */ - foreach ($records as $record) { + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, type: 'backup_schedule.run_now', inputs: [ 'backup_schedule_id' => (int) $record->getKey(), ], - initiator: $user + initiator: $userModel ); if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; + Notification::make() + ->title('Run already queued') + ->body('This schedule already has a queued or running backup.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + + return; } $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); @@ -660,6 +428,17 @@ public static function table(Table $table): Table } if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Run already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + $operationRunService->updateRun( $operationRun, status: 'completed', @@ -675,11 +454,9 @@ public static function table(Table $table): Table ], ); - continue; + return; } - $createdRunIds[] = (int) $run->id; - $operationRun->update([ 'context' => array_merge($operationRun->context ?? [], [ 'backup_schedule_id' => (int) $record->getKey(), @@ -698,83 +475,72 @@ public static function table(Table $table): Table 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'bulk_run_now', + 'trigger' => 'run_now', ], ], ); $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }, emitQueuedNotification: false); - } + }); - $notification = Notification::make() - ->title('Runs dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); - - if (count($createdRunIds) === 0) { - $notification->warning(); - } else { - $notification->success(); - } - - if ($user instanceof User) { - $notification->actions([ - Action::make('view_runs') - ->label('View in Operations') - ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); - } - - $notification->send(); - - if (count($createdRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - } - }), - BulkAction::make('bulk_retry') - ->label('Retry') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->visible(function (): bool { - $tenant = Tenant::current(); + OperationUxPresenter::queuedToast((string) $operationRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forAction( + Action::make('retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); - abort_unless($tenant instanceof Tenant, 403); - abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); + return; + } - if ($records->isEmpty()) { - return; - } + $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); - $tenant = Tenant::current(); - $userId = auth()->id(); - $user = $userId ? User::query()->find($userId) : null; - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - - $bulkRun = null; - - $createdRunIds = []; - - /** @var BackupSchedule $record */ - foreach ($records as $record) { + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); $operationRun = $operationRunService->ensureRun( tenant: $tenant, type: 'backup_schedule.retry', inputs: [ 'backup_schedule_id' => (int) $record->getKey(), ], - initiator: $user + initiator: $userModel ); if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; + Notification::make() + ->title('Retry already queued') + ->body('This schedule already has a queued or running retry.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + + return; } $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); @@ -797,6 +563,17 @@ public static function table(Table $table): Table } if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Retry already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + $operationRunService->updateRun( $operationRun, status: 'completed', @@ -812,11 +589,9 @@ public static function table(Table $table): Table ], ); - continue; + return; } - $createdRunIds[] = (int) $run->id; - $operationRun->update([ 'context' => array_merge($operationRun->context ?? [], [ 'backup_schedule_id' => (int) $record->getKey(), @@ -835,47 +610,328 @@ public static function table(Table $table): Table 'backup_schedule_id' => $record->id, 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'bulk_retry', + 'trigger' => 'retry', ], ], ); $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); - }, emitQueuedNotification: false); - } + }); - $notification = Notification::make() - ->title('Retries dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); - - if (count($createdRunIds) === 0) { - $notification->warning(); - } else { - $notification->success(); - } - - if ($user instanceof User) { - $notification->actions([ - Action::make('view_runs') - ->label('View in Operations') - ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); - } - - $notification->send(); - - if (count($createdRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - } - }), - DeleteBulkAction::make('bulk_delete') - ->visible(function (): bool { - $tenant = Tenant::current(); + OperationUxPresenter::queuedToast((string) $operationRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($operationRun, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forAction( + EditAction::make() + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), + UiEnforcement::forAction( + DeleteAction::make() + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + UiEnforcement::forBulkAction( + BulkAction::make('bulk_run_now') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); - }), + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); + + return; + } + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $bulkRun = null; + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.run_now', + inputs: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { + continue; + } + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + failures: [ + [ + 'code' => 'SCHEDULE_CONFLICT', + 'message' => 'Unable to queue a unique backup schedule run.', + ], + ], + ); + + continue; + } + + $createdRunIds[] = (int) $run->id; + + $operationRun->update([ + 'context' => array_merge($operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $record->getKey(), + 'backup_schedule_run_id' => (int) $run->getKey(), + ]), + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_run_now', + ], + ], + ); + + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + }, emitQueuedNotification: false); + } + + $notification = Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $notification->actions([ + Action::make('view_runs') + ->label('View in Operations') + ->url(OperationRunLinks::index($tenant)), + ])->sendToDatabase($user); + } + + $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forBulkAction( + BulkAction::make('bulk_retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); + + return; + } + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $bulkRun = null; + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $operationRun = $operationRunService->ensureRun( + tenant: $tenant, + type: 'backup_schedule.retry', + inputs: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { + continue; + } + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + + for ($i = 0; $i < 5; $i++) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); + } + } + + if (! $run instanceof BackupScheduleRun) { + $operationRunService->updateRun( + $operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'backup_schedule_id' => (int) $record->getKey(), + ], + failures: [ + [ + 'code' => 'SCHEDULE_CONFLICT', + 'message' => 'Unable to queue a unique backup schedule retry run.', + ], + ], + ); + + continue; + } + + $createdRunIds[] = (int) $run->id; + + $operationRun->update([ + 'context' => array_merge($operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $record->getKey(), + 'backup_schedule_run_id' => (int) $run->getKey(), + ]), + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_retry', + ], + ], + ); + + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + }, emitQueuedNotification: false); + } + + $notification = Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); + } + + if ($user instanceof User) { + $notification->actions([ + Action::make('view_runs') + ->label('View in Operations') + ->url(OperationRunLinks::index($tenant)), + ])->sendToDatabase($user); + } + + $notification->send(); + + if (count($createdRunIds) > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + } + }) + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(), + UiEnforcement::forBulkAction( + DeleteBulkAction::make('bulk_delete') + ) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 068f781..e92e30c 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -10,21 +10,23 @@ use App\Models\BackupSet; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -47,7 +49,18 @@ class BackupSetResource extends Resource public static function canCreate(): bool { - return UiEnforcement::for(Capabilities::TENANT_SYNC)->isAllowed(); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); } public static function form(Schema $schema): Schema @@ -89,343 +102,356 @@ public static function table(Table $table): Table ->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), ActionGroup::make([ - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false) - ->apply( - Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + UiEnforcement::forAction( + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); - $record->restore(); - $record->items()->withTrashed()->restore(); + $record->restore(); + $record->items()->withTrashed()->restore(); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.restored', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.restored', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } + Notification::make() + ->title('Backup set restored') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => ! $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); + + $record->delete(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.deleted', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } + + Notification::make() + ->title('Backup set archived') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Filament::getTenant(); + + if ($record->restoreRuns()->withTrashed()->exists()) { Notification::make() - ->title('Backup set restored') - ->success() + ->title('Cannot force delete backup set') + ->body('Backup sets referenced by restore runs cannot be removed.') + ->danger() ->send(); - }), - ), - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->andVisibleWhen(fn (?BackupSet $record): bool => $record ? ! $record->trashed() : false) - ->apply( - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + return; + } - $record->delete(); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.force_deleted', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.deleted', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } + $record->items()->withTrashed()->forceDelete(); + $record->forceDelete(); - Notification::make() - ->title('Backup set archived') - ->success() - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false) - ->apply( - Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->action(function (BackupSet $record, AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); - - if ($record->restoreRuns()->withTrashed()->exists()) { - Notification::make() - ->title('Cannot force delete backup set') - ->body('Backup sets referenced by restore runs cannot be removed.') - ->danger() - ->send(); - - return; - } - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.force_deleted', - resourceType: 'backup_set', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['name' => $record->name]] - ); - } - - $record->items()->withTrashed()->forceDelete(); - $record->forceDelete(); - - Notification::make() - ->title('Backup set permanently deleted') - ->success() - ->send(); - }), - ), + Notification::make() + ->title('Backup set permanently deleted') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->andHiddenWhen(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - $isOnlyTrashed = in_array($value, [0, '0', false], true); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return $isOnlyTrashed; - }) - ->apply( - BulkAction::make('bulk_delete') - ->label('Archive Backup Sets') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 10) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + return $isOnlyTrashed; + }) + ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + if (! $tenant instanceof Tenant) { + return; + } - $initiator = $user instanceof User ? $user : null; + $initiator = $user instanceof User ? $user : null; - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - OperationUxPresenter::queuedToast('backup_set.delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - ), + OperationUxPresenter::queuedToast('backup_set.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->andHiddenWhen(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Backup Sets') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - $isOnlyTrashed = in_array($value, [0, '0', false], true); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return ! $isOnlyTrashed; - }) - ->apply( - BulkAction::make('bulk_restore') - ->label('Restore Backup Sets') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") - ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') - ->action(function (Collection $records) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") + ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + if (! $tenant instanceof Tenant) { + return; + } - $initiator = $user instanceof User ? $user : null; + $initiator = $user instanceof User ? $user : null; - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetRestoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - OperationUxPresenter::queuedToast('backup_set.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - ), + OperationUxPresenter::queuedToast('backup_set.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->andHiddenWhen(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_force_delete') + ->label('Force Delete Backup Sets') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - $isOnlyTrashed = in_array($value, [0, '0', false], true); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return ! $isOnlyTrashed; - }) - ->apply( - BulkAction::make('bulk_force_delete') - ->label('Force Delete Backup Sets') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") - ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 10) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") + ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + if (! $tenant instanceof Tenant) { + return; + } - $initiator = $user instanceof User ? $user : null; + $initiator = $user instanceof User ? $user : null; - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'backup_set.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkBackupSetForceDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - backupSetIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'backup_set_count' => $count, - ], - emitQueuedNotification: false, - ); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); - OperationUxPresenter::queuedToast('backup_set.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - ), + OperationUxPresenter::queuedToast('backup_set.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 737b890..75e0d25 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -9,7 +9,6 @@ use App\Models\User; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -17,6 +16,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void public function table(Table $table): Table { + $refreshTable = Actions\Action::make('refreshTable') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $this->resetTable(); + }); + + $addPolicies = Actions\Action::make('addPolicies') + ->label('Add Policies') + ->icon('heroicon-o-plus') + ->tooltip('You do not have permission to add policies.') + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { + $backupSet = $this->getOwnerRecord(); + + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); + }); + + UiEnforcement::forAction($addPolicies) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to add policies.') + ->apply(); + + $removeItem = Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record): void { + $backupSet = $this->getOwnerRecord(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } + + if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { + abort(404); + } + + $backupItemIds = [(int) $record->getKey()]; + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'backup_set.remove_policies', + inputs: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'backup_item_ids' => $backupItemIds, + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Removal already queued') + ->body('A matching remove operation is already queued or running.') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }); + + UiEnforcement::forAction($removeItem) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to remove policies.') + ->apply(); + + $bulkRemove = Actions\BulkAction::make('bulk_remove') + ->label('Remove selected') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->deselectRecordsAfterCompletion() + ->action(function (Collection $records): void { + if ($records->isEmpty()) { + return; + } + + $backupSet = $this->getOwnerRecord(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } + + if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) { + abort(404); + } + + $backupItemIds = $records + ->pluck('id') + ->map(fn (mixed $value): int => (int) $value) + ->filter(fn (int $value): bool => $value > 0) + ->unique() + ->sort() + ->values() + ->all(); + + if ($backupItemIds === []) { + return; + } + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'backup_set.remove_policies', + inputs: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'backup_item_ids' => $backupItemIds, + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Removal already queued') + ->body('A matching remove operation is already queued or running.') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { + RemovePoliciesFromBackupSetJob::dispatch( + backupSetId: (int) $backupSet->getKey(), + backupItemIds: $backupItemIds, + initiatorUserId: (int) $user->getKey(), + operationRun: $opRun, + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }); + + UiEnforcement::forBulkAction($bulkRemove) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to remove policies.') + ->apply(); + return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->columns([ @@ -125,27 +318,8 @@ public function table(Table $table): Table ]) ->filters([]) ->headerActions([ - Actions\Action::make('refreshTable') - ->label('Refresh') - ->icon('heroicon-o-arrow-path') - ->action(function (): void { - $this->resetTable(); - }), - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( - Actions\Action::make('addPolicies') - ->label('Add Policies') - ->icon('heroicon-o-plus') - ->modalHeading('Add Policies') - ->modalSubmitAction(false) - ->modalCancelActionLabel('Close') - ->modalContent(function (): View { - $backupSet = $this->getOwnerRecord(); - - return view('filament.modals.backup-set-policy-picker', [ - 'backupSetId' => $backupSet->getKey(), - ]); - }), - ), + $refreshTable, + $addPolicies, ]) ->actions([ Actions\ActionGroup::make([ @@ -162,156 +336,12 @@ public function table(Table $table): Table }) ->hidden(fn (BackupItem $record) => ! $record->policy_id) ->openUrlInNewTab(true), - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( - Actions\Action::make('remove') - ->label('Remove') - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (BackupItem $record): void { - $backupSet = $this->getOwnerRecord(); - $tenant = $backupSet->tenant; - - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->authorizeOrAbort($tenant); - - /** @var User $user */ - $user = auth()->user(); - - $backupItemIds = [(int) $record->getKey()]; - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'backup_set.remove_policies', - inputs: [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'backup_item_ids' => $backupItemIds, - ], - initiator: $user, - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { - RemovePoliciesFromBackupSetJob::dispatch( - backupSetId: (int) $backupSet->getKey(), - backupItemIds: $backupItemIds, - initiatorUserId: (int) $user->getKey(), - operationRun: $opRun, - ); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), - ), + $removeItem, ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ Actions\BulkActionGroup::make([ - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( - 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(); - $tenant = $backupSet->tenant; - - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->authorizeOrAbort($tenant); - - /** @var User $user */ - $user = auth()->user(); - - $backupItemIds = $records - ->pluck('id') - ->map(fn (mixed $value): int => (int) $value) - ->filter(fn (int $value): bool => $value > 0) - ->unique() - ->sort() - ->values() - ->all(); - - if ($backupItemIds === []) { - return; - } - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'backup_set.remove_policies', - inputs: [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'backup_item_ids' => $backupItemIds, - ], - initiator: $user, - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void { - RemovePoliciesFromBackupSetJob::dispatch( - backupSetId: (int) $backupSet->getKey(), - backupItemIds: $backupItemIds, - initiatorUserId: (int) $user->getKey(), - operationRun: $opRun, - ); - }); - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), - ), + $bulkRemove, ]), ]); } diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index 3f786b6..998064e 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -11,8 +11,8 @@ use App\Services\Directory\EntraGroupSelection; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; @@ -29,61 +29,90 @@ protected function getHeaderActions(): array ->icon('heroicon-o-clock') ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) ->visible(fn (): bool => (bool) Tenant::current()), + UiEnforcement::forAction( + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function (): void { + $user = auth()->user(); + $tenant = Tenant::current(); - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Action::make('sync_groups') - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->action(function (): void { - UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; + } - $user = auth()->user(); - $tenant = Tenant::current(); + $selectionKey = EntraGroupSelection::allGroupsV1(); - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return; - } + // --- Phase 3: Canonical Operation Run Start --- + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => $selectionKey], + initiator: $user + ); - $selectionKey = EntraGroupSelection::allGroupsV1(); + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { + Notification::make() + ->title('Group sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - // --- Phase 3: Canonical Operation Run Start --- - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'directory_groups.sync', - inputs: ['selection_key' => $selectionKey], - initiator: $user - ); + return; + } + // ---------------------------------------------- + + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + Notification::make() + ->title('Group sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->sendToDatabase($user) + ->send(); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + operationRun: $opRun + )); - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - // ---------------------------------------------- - - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() + ->title('Group sync started') + ->body('Sync dispatched.') + ->success() ->actions([ Action::make('view_run') ->label('View Run') @@ -91,38 +120,11 @@ protected function getHeaderActions(): array ]) ->sendToDatabase($user) ->send(); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - operationRun: $opRun - )); - - Notification::make() - ->title('Group sync started') - ->body('Sync dispatched.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->sendToDatabase($user) - ->send(); - })), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to sync groups.') + ->apply(), ]; } } diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index f8d7a23..ff0fecb 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -10,7 +10,8 @@ use App\Notifications\RunStatusChangedNotification; use App\Services\Directory\EntraGroupSelection; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; @@ -21,14 +22,12 @@ class ListEntraGroupSyncRuns extends ListRecords protected function getHeaderActions(): array { return [ - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( + UiEnforcement::forAction( Action::make('sync_groups') ->label('Sync Groups') ->icon('heroicon-o-arrow-path') ->color('warning') ->action(function (): void { - UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); - $user = auth()->user(); $tenant = Tenant::current(); @@ -38,49 +37,52 @@ protected function getHeaderActions(): array $selectionKey = EntraGroupSelection::allGroupsV1(); - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); - if ($existing instanceof EntraGroupSyncRun) { - $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); $user->notify(new RunStatusChangedNotification([ 'tenant_id' => (int) $tenant->getKey(), 'run_type' => 'directory_groups', - 'run_id' => (int) $existing->getKey(), - 'status' => $normalizedStatus, + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', ])); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - )); - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $run->getKey(), - 'status' => 'queued', - ])); - }), - ), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index e0d1265..968c6e6 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -12,10 +12,13 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use BackedEnum; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -29,7 +32,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class FindingResource extends Resource @@ -46,19 +48,34 @@ public static function canViewAny(): bool { $tenant = Tenant::current(); - return $tenant instanceof Tenant - && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return $user->can(Capabilities::TENANT_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + if (! $user->canAccessTenant($tenant)) { + return false; + } + + if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) { return false; } @@ -343,75 +360,62 @@ public static function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('acknowledge_selected') - ->label('Acknowledge selected') - ->icon('heroicon-o-check') - ->color('gray') - ->authorize(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + UiEnforcement::forBulkAction( + BulkAction::make('acknowledge_selected') + ->label('Acknowledge selected') + ->icon('heroicon-o-check') + ->color('gray') + ->requiresConfirmation() + ->action(function (Collection $records): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); - if (! $tenant || ! $user instanceof User) { - return false; - } - - $probe = new Finding(['tenant_id' => $tenant->getKey()]); - - return $user->can('update', $probe); - }) - ->authorizeIndividualRecords('update') - ->requiresConfirmation() - ->action(function (Collection $records): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant || ! $user instanceof User) { - return; - } - - $firstRecord = $records->first(); - if ($firstRecord instanceof Finding) { - Gate::authorize('update', $firstRecord); - } - - $acknowledgedCount = 0; - $skippedCount = 0; - - foreach ($records as $record) { - if (! $record instanceof Finding) { - $skippedCount++; - - continue; + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; } - if ((int) $record->tenant_id !== (int) $tenant->getKey()) { - $skippedCount++; + $acknowledgedCount = 0; + $skippedCount = 0; - continue; + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if ($record->status !== Finding::STATUS_NEW) { + $skippedCount++; + + continue; + } + + $record->acknowledge($user); + $acknowledgedCount++; } - if ($record->status !== Finding::STATUS_NEW) { - $skippedCount++; - - continue; + $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; } - $record->acknowledge($user); - $acknowledgedCount++; - } - - $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; - if ($skippedCount > 0) { - $body .= " Skipped {$skippedCount}."; - } - - Notification::make() - ->title('Bulk acknowledge completed') - ->body($body) - ->success() - ->send(); - }) - ->deselectRecordsAfterCompletion(), + Notification::make() + ->title('Bulk acknowledge completed') + ->body($body) + ->success() + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 0322a04..612baa1 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -4,15 +4,15 @@ use App\Filament\Resources\FindingResource; use App\Models\Finding; -use App\Models\Tenant; -use App\Models\User; +use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Gate; class ListFindings extends ListRecords { @@ -21,101 +21,83 @@ class ListFindings extends ListRecords protected function getHeaderActions(): array { return [ - Actions\Action::make('acknowledge_all_matching') - ->label('Acknowledge all matching') - ->icon('heroicon-o-check') - ->color('gray') - ->requiresConfirmation() - ->authorize(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); + UiEnforcement::forAction( + Actions\Action::make('acknowledge_all_matching') + ->label('Acknowledge all matching') + ->icon('heroicon-o-check') + ->color('gray') + ->requiresConfirmation() + ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) + ->modalDescription(function (): string { + $count = $this->getAllMatchingCount(); - if (! $tenant || ! $user instanceof User) { - return false; - } + return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; + }) + ->form(function (): array { + $count = $this->getAllMatchingCount(); - $probe = new Finding(['tenant_id' => $tenant->getKey()]); + if ($count <= 100) { + return []; + } - return $user->can('update', $probe); - }) - ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) - ->modalDescription(function (): string { - $count = $this->getAllMatchingCount(); + return [ + TextInput::make('confirmation') + ->label('Type ACKNOWLEDGE to confirm') + ->required() + ->in(['ACKNOWLEDGE']) + ->validationMessages([ + 'in' => 'Please type ACKNOWLEDGE to confirm.', + ]), + ]; + }) + ->action(function (array $data): void { + $query = $this->buildAllMatchingQuery(); + $count = (clone $query)->count(); - return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; - }) - ->form(function (): array { - $count = $this->getAllMatchingCount(); + if ($count === 0) { + Notification::make() + ->title('No matching findings') + ->body('There are no new findings matching the current filters.') + ->warning() + ->send(); - if ($count <= 100) { - return []; - } + return; + } - return [ - TextInput::make('confirmation') - ->label('Type ACKNOWLEDGE to confirm') - ->required() - ->in(['ACKNOWLEDGE']) - ->validationMessages([ - 'in' => 'Please type ACKNOWLEDGE to confirm.', - ]), - ]; - }) - ->action(function (array $data): void { - $tenant = Tenant::current(); - $user = auth()->user(); + $updated = $query->update([ + 'status' => Finding::STATUS_ACKNOWLEDGED, + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => auth()->id(), + ]); - if (! $tenant || ! $user instanceof User) { - return; - } + $this->deselectAllTableRecords(); + $this->resetPage(); - $query = $this->buildAllMatchingQuery(); - $count = (clone $query)->count(); - - if ($count === 0) { Notification::make() - ->title('No matching findings') - ->body('There are no new findings matching the current filters.') - ->warning() + ->title('Bulk acknowledge completed') + ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') + ->success() ->send(); - - return; - } - - $firstRecord = (clone $query)->first(); - if ($firstRecord instanceof Finding) { - Gate::authorize('update', $firstRecord); - } - - $updated = $query->update([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), - ]); - - $this->deselectAllTableRecords(); - $this->resetPage(); - - Notification::make() - ->title('Bulk acknowledge completed') - ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') - ->success() - ->send(); - }), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } protected function buildAllMatchingQuery(): Builder { - $tenant = Tenant::current(); - $query = Finding::query(); - if (! $tenant) { + $tenantId = \Filament\Facades\Filament::getTenant()?->getKey(); + + if (! is_numeric($tenantId)) { return $query->whereRaw('1 = 0'); } - $query->where('tenant_id', $tenant->getKey()); + $query->where('tenant_id', (int) $tenantId); $query->where('status', Finding::STATUS_NEW); diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index b88707e..0e38b89 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -6,10 +6,11 @@ use App\Filament\Resources\InventoryItemResource\Pages; use App\Models\InventoryItem; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -18,7 +19,6 @@ use App\Support\Inventory\InventoryPolicyTypeMeta; use BackedEnum; use Filament\Actions; -use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -44,18 +44,35 @@ class InventoryItemResource extends Resource public static function canViewAny(): bool { - return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed(); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + $capabilityResolver = app(CapabilityResolver::class); + + return $capabilityResolver->isMember($user, $tenant) + && $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { - $tenant = Filament::getTenant(); + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant) { + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) { + $capabilityResolver = app(CapabilityResolver::class); + + if (! $capabilityResolver->isMember($user, $tenant)) { + return false; + } + + if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index dccfe65..e15908a 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -12,11 +12,12 @@ use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions\Action; use Filament\Actions\Action as HintAction; use Filament\Forms\Components\Hidden; @@ -40,7 +41,7 @@ protected function getHeaderWidgets(): array protected function getHeaderActions(): array { return [ - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply( + UiEnforcement::forAction( Action::make('run_inventory_sync') ->label('Run Inventory Sync') ->icon('heroicon-o-arrow-path') @@ -106,9 +107,20 @@ protected function getHeaderActions(): array ->default(fn (): ?string => Tenant::current()?->getKey()) ->dehydrated(), ]) - ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort(); + ->visible(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return false; + } + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return false; + } + + return $user->canAccessTenant($tenant); + }) + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { $tenant = Tenant::current(); $user = auth()->user(); @@ -123,113 +135,117 @@ protected function getHeaderActions(): array ->danger() ->send(); - throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, 'Not allowed'); + return; } - $selectionPayload = $inventorySyncService->defaultSelectionPayload(); - if (array_key_exists('policy_types', $data)) { - $selectionPayload['policy_types'] = $data['policy_types']; - } - if (array_key_exists('include_foundations', $data)) { - $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; - } - if (array_key_exists('include_dependencies', $data)) { - $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; - } - $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); + $selectionPayload = $inventorySyncService->defaultSelectionPayload(); + if (array_key_exists('policy_types', $data)) { + $selectionPayload['policy_types'] = $data['policy_types']; + } + if (array_key_exists('include_foundations', $data)) { + $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; + } + if (array_key_exists('include_dependencies', $data)) { + $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; + } + $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user - ); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user + ); - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Inventory sync already active') - ->body('This operation is already queued or running.') - ->warning() + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Inventory sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + return; + } + + // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) + $existing = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $computed['selection_hash']) + ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) + ->first(); + + // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. + if ($existing instanceof InventorySyncRun) { + Notification::make() + ->title('Inventory sync already active') + ->body('A matching inventory sync run is already pending or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); + + $policyTypes = $computed['selection']['policy_types'] ?? []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.dispatched', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'selection_hash' => $run->selection_hash, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + RunInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + inventorySyncRunId: (int) $run->id, + operationRun: $opRun + ); + }); + + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Action::make('view_run') - ->label('View Run') + ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - return; - } - - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.dispatched', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { - RunInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, - operationRun: $opRun - ); - }); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - }), - ), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), ]; } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 341eea0..aedaf04 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -6,14 +6,14 @@ use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use BackedEnum; use Filament\Actions; -use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -41,18 +41,32 @@ class InventorySyncRunResource extends Resource public static function canViewAny(): bool { - return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed(); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); } public static function canView(Model $record): bool { - $tenant = Filament::getTenant(); + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant) { + if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } - if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { return false; } diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 51dc990..f2260eb 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -22,6 +22,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -41,7 +42,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyResource extends Resource @@ -363,569 +363,531 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ - Actions\Action::make('ignore') - ->label('Ignore') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (Policy $record): bool => $record->ignored_at === null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (Policy $record, HasTable $livewire) { - $tenant = Tenant::current(); + UiEnforcement::forTableAction( + Actions\Action::make('ignore') + ->label('Ignore') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->action(function (Policy $record): void { + $record->ignore(); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->ignore(); - - Notification::make() - ->title('Policy ignored') - ->success() - ->send(); - }), - Actions\Action::make('restore') - ->label('Restore') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->visible(fn (Policy $record): bool => $record->ignored_at !== null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->action(function (Policy $record) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->unignore(); - - Notification::make() - ->title('Policy restored') - ->success() - ->send(); - }), - Actions\Action::make('sync') - ->label('Sync') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (Policy $record): bool { - if ($record->ignored_at !== null) { - return false; - } - - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return false; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); - }) - ->action(function (Policy $record, HasTable $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! $user instanceof User) { - abort(403); - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync_one', - inputs: [ - 'scope' => 'one', - 'policy_id' => (int) $record->getKey(), - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + ->title('Policy ignored') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to ignore policies.') + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('restore') + ->label('Restore') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at !== null) + ->action(function (Policy $record): void { + $record->unignore(); + + Notification::make() + ->title('Policy restored') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to restore policies.') + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('sync') + ->label('Sync') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->action(function (Policy $record, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync_one', + inputs: [ + 'scope' => 'one', + 'policy_id' => (int) $record->getKey(), + ], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('export') + ->label('Export to Backup') + ->icon('heroicon-o-archive-box-arrow-down') + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->form([ + Forms\Components\TextInput::make('backup_name') + ->label('Backup Name') + ->required() + ->default(fn () => 'Backup '.now()->toDateTimeString()), + ]) + ->action(function (Policy $record, array $data): void { + $tenant = Tenant::current(); + $user = auth()->user(); - return; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), - Actions\Action::make('export') - ->label('Export to Backup') - ->icon('heroicon-o-archive-box-arrow-down') - ->visible(fn (Policy $record): bool => $record->ignored_at === null) - ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->form([ - Forms\Components\TextInput::make('backup_name') - ->label('Backup Name') - ->required() - ->default(fn () => 'Backup '.now()->toDateTimeString()), - ]) - ->action(function (Policy $record, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); + if (! $user instanceof User) { + abort(403); + } - if (! $user instanceof User) { - abort(403); - } + $ids = [(int) $record->getKey()]; - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); - $ids = [(int) $record->getKey()]; + $selectionIdentity = $selection->fromIds($ids); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.export', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void { - BulkPolicyExportJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - backupName: (string) $data['backup_name'], - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'backup_name' => (string) $data['backup_name'], - 'policy_count' => 1, - ], - emitQueuedNotification: false, - ); - - 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'), - ]) - ->bulkActions([ - BulkActionGroup::make([ - BulkAction::make('bulk_delete') - ->label('Ignore Policies') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return $value === 'ignored'; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->form(function (Collection $records) { - if ($records->count() >= 20) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } - - return []; - }) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - return; - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids): void { - BulkPolicyDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); - - Notification::make() - ->title('Policy delete queued') - ->body("Queued deletion for {$count} policies.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($user) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_restore') - ->label('Restore Policies') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return ! in_array($value, [null, 'ignored'], true); - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->action(function (Collection $records, HasTable $livewire) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - abort(403); - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.unignore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void { - if ($count >= 20) { - BulkPolicyUnignoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - - return; - } - - BulkPolicyUnignoreJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); - - if ($count >= 20) { - Notification::make() - ->title('Bulk restore started') - ->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->duration(8000) - ->sendToDatabase($user) - ->send(); - } - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_sync') - ->label('Sync Policies') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return true; - } - - $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return true; - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - return true; - } - - $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; - $value = $visibilityFilterState['value'] ?? null; - - return $value === 'ignored'; - }) - ->action(function (Collection $records, HasTable $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - if (! $user instanceof User) { - abort(403); - } - - if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - $ids = $records - ->pluck('id') - ->map(static fn ($id): int => (int) $id) - ->unique() - ->sort() - ->values() - ->all(); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync', - inputs: [ - 'scope' => 'subset', - 'policy_ids' => $ids, - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_export') - ->label('Export to Backup') - ->icon('heroicon-o-archive-box-arrow-down') - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); - }) - ->form([ - Forms\Components\TextInput::make('backup_name') - ->label('Backup Name') - ->required() - ->default(fn () => 'Backup '.now()->toDateTimeString()), - ]) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $user instanceof User) { - abort(403); - } - - abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy.export', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void { - if ($count >= 20) { - BulkPolicyExportJob::dispatch( + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void { + BulkPolicyExportJob::dispatchSync( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), policyIds: $ids, backupName: (string) $data['backup_name'], operationRun: $operationRun, ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => 1, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Ignore Policies') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; - BulkPolicyExportJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - policyIds: $ids, - backupName: (string) $data['backup_name'], - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'backup_name' => (string) $data['backup_name'], - 'policy_count' => $count, - ], - emitQueuedNotification: false, - ); + return $value === 'ignored'; + }) + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + /** @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.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids): void { + BulkPolicyDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); - if ($count >= 20) { Notification::make() - ->title('Bulk export started') - ->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.") + ->title('Policy delete queued') + ->body("Queued deletion for {$count} policies.") ->icon('heroicon-o-arrow-path') ->iconColor('warning') ->info() + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) ->duration(8000) ->sendToDatabase($user) ->send(); - } + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Policies') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return ! in_array($value, [null, 'ignored'], true); + }) + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @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.unignore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void { + if ($count >= 20) { + BulkPolicyUnignoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyUnignoreJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + } + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('bulk_sync') + ->label('Sync Policies') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return $value === 'ignored'; + }) + ->action(function (Collection $records, HasTable $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + $ids = $records + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->all(); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => $ids, + ], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('bulk_export') + ->label('Export to Backup') + ->icon('heroicon-o-archive-box-arrow-down') + ->form([ + Forms\Components\TextInput::make('backup_name') + ->label('Backup Name') + ->required() + ->default(fn () => 'Backup '.now()->toDateTimeString()), + ]) + ->action(function (Collection $records, array $data): void { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + /** @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.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void { + if ($count >= 20) { + BulkPolicyExportJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyExportJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); + + if ($count >= 20) { + Notification::make() + ->title('Bulk export started') + ->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + } + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), ]), ]); } diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index c13d94a..31a9eba 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -11,10 +11,10 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; -use Illuminate\Support\Facades\Gate; class ListPolicies extends ListRecords { @@ -23,109 +23,70 @@ class ListPolicies extends ListRecords protected function getHeaderActions(): array { return [ - Actions\Action::make('sync') - ->label('Sync from Intune') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (): bool { - $user = auth()->user(); + UiEnforcement::forAction( + Actions\Action::make('sync') + ->label('Sync from Intune') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->action(function (self $livewire): void { + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $user instanceof User) { - return false; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } - $tenant = Tenant::current(); + $requestedTypes = array_map( + static fn (array $typeConfig): string => (string) $typeConfig['type'], + config('tenantpilot.supported_policy_types', []) + ); - return $tenant instanceof Tenant - && $user->canAccessTenant($tenant); - }) - ->disabled(function (): bool { - $user = auth()->user(); - $tenant = Tenant::current(); + sort($requestedTypes); - return ! ($user instanceof User - && $tenant instanceof Tenant - && Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); - }) - ->tooltip(function (): ?string { - $user = auth()->user(); - $tenant = Tenant::current(); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: [ + 'scope' => 'all', + 'types' => $requestedTypes, + ], + initiator: $user + ); - if (! ($user instanceof User && $tenant instanceof Tenant)) { - return null; - } + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Policy sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) - ? null - : 'You do not have permission to sync policies.'; - }) - ->action(function (self $livewire): void { - $tenant = Tenant::current(); - $user = auth()->user(); + return; + } - if (! $user instanceof User) { - abort(403); - } - - if (! $tenant instanceof Tenant) { - abort(403); - } - - if (! $user->canAccessTenant($tenant)) { - abort(403); - } - - if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { - abort(403); - } - - $requestedTypes = array_map( - static fn (array $typeConfig): string => (string) $typeConfig['type'], - config('tenantpilot.supported_policy_types', []) - ); - - sort($requestedTypes); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync', - inputs: [ - 'scope' => 'all', - 'types' => $requestedTypes, - ], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { + SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); + }); + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); - - return; - } - - $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { - SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); - }); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }), + }) + ) + ->requireCapability(Capabilities::TENANT_SYNC) + ->tooltip('You do not have permission to sync policies.') + ->destructive() + ->apply(), ]; } } diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index b3778d4..b63186b 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -5,17 +5,20 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\RestoreService; use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; +use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\UiTooltips; use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Support\Facades\Gate; class VersionsRelationManager extends RelationManager { @@ -23,6 +26,116 @@ class VersionsRelationManager extends RelationManager public function table(Table $table): Table { + $restoreToIntune = Actions\Action::make('restore_to_intune') + ->label('Restore to Intune') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") + ->modalSubheading('Creates a restore run using this policy version snapshot.') + ->form([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + ]) + ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + Notification::make() + ->title('Missing tenant or user context.') + ->danger() + ->send(); + + return; + } + + if ($record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + try { + $run = $restoreService->executeFromPolicyVersion( + tenant: $tenant, + version: $record, + dryRun: (bool) ($data['is_dry_run'] ?? true), + actorEmail: $user->email, + actorName: $user->name, + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Restore run started') + ->success() + ->send(); + + return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); + }); + + UiEnforcement::forAction($restoreToIntune) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + + $restoreToIntune + ->disabled(function (PolicyVersion $record): bool { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return true; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return true; + } + + return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); + }) + ->tooltip(function (PolicyVersion $record): ?string { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return null; + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + return UiTooltips::INSUFFICIENT_PERMISSION; + } + + return null; + }); + return $table ->columns([ Tables\Columns\TextColumn::make('version_number')->sortable(), @@ -38,61 +151,7 @@ public function table(Table $table): Table ->filters([]) ->headerActions([]) ->actions([ - Actions\Action::make('restore_to_intune') - ->label('Restore to Intune') - ->icon('heroicon-o-arrow-path-rounded-square') - ->color('danger') - ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') - ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') - ->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant - && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)) - ->requiresConfirmation() - ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") - ->modalSubheading('Creates a restore run using this policy version snapshot.') - ->form([ - Forms\Components\Toggle::make('is_dry_run') - ->label('Preview only (dry-run)') - ->default(true), - ]) - ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { - $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - if ($record->tenant_id !== $tenant->id) { - Notification::make() - ->title('Policy version belongs to a different tenant') - ->danger() - ->send(); - - return; - } - - try { - $run = $restoreService->executeFromPolicyVersion( - tenant: $tenant, - version: $record, - dryRun: (bool) ($data['is_dry_run'] ?? true), - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title('Restore run failed to start') - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make() - ->title('Restore run started') - ->success() - ->send(); - - return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); - }), + $restoreToIntune, Actions\ViewAction::make() ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 681625c..bb703c6 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -11,6 +11,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; @@ -21,6 +22,7 @@ use App\Support\Badges\TagBadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Carbon\CarbonImmutable; use Filament\Actions; @@ -39,7 +41,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyVersionResource extends Resource @@ -183,6 +184,294 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): Table { + $bulkPruneVersions = BulkAction::make('bulk_prune_versions') + ->label('Prune Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->form(function (Collection $records) { + $fields = [ + Forms\Components\TextInput::make('retention_days') + ->label('Retention Days') + ->helperText('Versions captured within the last N days will be skipped.') + ->numeric() + ->required() + ->default(90) + ->minValue(1), + ]; + + if ($records->count() >= 20) { + $fields[] = Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]); + } + + return $fields; + }) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $retentionDays = (int) ($data['retention_days'] ?? 90); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.prune', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { + BulkPolicyVersionPruneJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + retentionDays: $retentionDays, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + 'retention_days' => $retentionDays, + ], + emitQueuedNotification: false, + ); + + Notification::make() + ->title('Policy version prune queued') + ->body("Queued prune for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->duration(8000) + ->sendToDatabase($initiator); + + OperationUxPresenter::queuedToast('policy_version.prune') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkPruneVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + + $bulkRestoreVersions = BulkAction::make('bulk_restore_versions') + ->label('Restore Versions') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") + ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('policy_version.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkRestoreVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + + $bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions') + ->label('Force Delete Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") + ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + abort(403); + } + + $initiator = $user; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + Notification::make() + ->title('Policy version force delete queued') + ->body("Queued force delete for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->duration(8000) + ->sendToDatabase($initiator); + + OperationUxPresenter::queuedToast('policy_version.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + + UiEnforcement::forBulkAction($bulkForceDeleteVersions) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); + return $table ->columns([ Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), @@ -208,614 +497,318 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), Actions\ActionGroup::make([ - Actions\Action::make('restore_via_wizard') - ->label('Restore via Wizard') - ->icon('heroicon-o-arrow-path-rounded-square') - ->color('primary') - ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only' - || ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) - ->tooltip(function (PolicyVersion $record): ?string { - if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) { - return 'You do not have permission to create restore runs.'; - } + (function (): Actions\Action { + $action = Actions\Action::make('restore_via_wizard') + ->label('Restore via Wizard') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") + ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') + ->visible(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); - if (($record->metadata['source'] ?? null) === 'metadata_only') { - return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } - return null; - }) - ->requiresConfirmation() - ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") - ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') - ->action(function (PolicyVersion $record) { - $tenant = Tenant::current(); - $user = auth()->user(); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + return $resolver->isMember($user, $tenant); + }) + ->disabled(function (PolicyVersion $record): bool { + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return true; + } - if (! $tenant || $record->tenant_id !== $tenant->id) { - Notification::make() - ->title('Policy version belongs to a different tenant') - ->danger() - ->send(); + $tenant = Tenant::current(); + $user = auth()->user(); - return; - } + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } - $policy = $record->policy; + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - if (! $policy) { - Notification::make() - ->title('Policy could not be found for this version') - ->danger() - ->send(); + if (! $resolver->isMember($user, $tenant)) { + return true; + } - return; - } + return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); + }) + ->tooltip(function (PolicyVersion $record): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); - $backupSet = BackupSet::create([ - 'tenant_id' => $tenant->id, - 'name' => sprintf( - 'Policy Version Restore • %s • v%d', - $policy->display_name, - $record->version_number - ), - 'created_by' => $user?->email, - 'status' => 'completed', - 'item_count' => 1, - 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + 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.'; + } + + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; + } + + return null; + }) + ->action(function (PolicyVersion $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } + + if (($record->metadata['source'] ?? null) === 'metadata_only') { + Notification::make() + ->title('Restore disabled for metadata-only snapshot') + ->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.') + ->warning() + ->send(); + + return; + } + + if (! $tenant || $record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + $policy = $record->policy; + + if (! $policy) { + Notification::make() + ->title('Policy could not be found for this version') + ->danger() + ->send(); + + return; + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $record->version_number + ), + 'created_by' => $user?->email, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ 'source' => 'policy_version', + 'display_name' => $policy->display_name, 'policy_version_id' => $record->id, 'policy_version_number' => $record->version_number, + 'version_captured_at' => $record->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, - ], - ]); + 'policy_version_id' => $record->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), + 'payload' => $record->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $record->assignments, + ]); - $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; - $scopeTagIds = $scopeTags['ids'] ?? null; - $scopeTagNames = $scopeTags['names'] ?? null; + return redirect()->to(RestoreRunResource::getUrl('create', [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])); + }); - $backupItemMetadata = [ - 'source' => 'policy_version', - 'display_name' => $policy->display_name, - 'policy_version_id' => $record->id, - 'policy_version_number' => $record->version_number, - 'version_captured_at' => $record->captured_at?->toIso8601String(), - ]; + return $action; + })(), + (function (): Actions\Action { + $action = Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => ! $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - if (is_array($scopeTagIds) && $scopeTagIds !== []) { - $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(403); + } - if (is_array($scopeTagNames) && $scopeTagNames !== []) { - $backupItemMetadata['scope_tag_names'] = $scopeTagNames; - } + $record->delete(); - $backupItem = BackupItem::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'policy_id' => $policy->id, - 'policy_version_id' => $record->id, - 'policy_identifier' => $policy->external_id, - 'policy_type' => $policy->policy_type, - 'platform' => $policy->platform, - 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), - 'payload' => $record->snapshot ?? [], - 'metadata' => $backupItemMetadata, - 'assignments' => $record->assignments, - ]); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - return redirect()->to(RestoreRunResource::getUrl('create', [ - 'backup_set_id' => $backupSet->id, - 'scope_mode' => 'selected', - 'backup_item_ids' => [$backupItem->id], - ])); - }), - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => ! $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; + Notification::make() + ->title('Policy version archived') + ->success() + ->send(); + }); - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (PolicyVersion $record): ?string { - $user = auth()->user(); - $tenant = $record->tenant; + return $action; + })(), + (function (): Actions\Action { + $action = Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(403); + } - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.force_deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->forceDelete(); - $record->delete(); + Notification::make() + ->title('Policy version permanently deleted') + ->success() + ->send(); + }); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.deleted', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - Notification::make() - ->title('Policy version archived') - ->success() - ->send(); - }), - Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; + return $action; + })(), - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } + (function (): Actions\Action { + $action = Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; - 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) { + abort(403); + } - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } + $record->restore(); - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.restored', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + Notification::make() + ->title('Policy version restored') + ->success() + ->send(); + }); - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.force_deleted', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } + UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip('You do not have permission to manage policy versions.') + ->apply(); - $record->forceDelete(); - - Notification::make() - ->title('Policy version permanently deleted') - ->success() - ->send(); - }), - - Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->visible(fn (PolicyVersion $record) => $record->trashed()) - ->disabled(function (PolicyVersion $record): bool { - $user = auth()->user(); - $tenant = $record->tenant; - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (PolicyVersion $record): ?string { - $user = auth()->user(); - $tenant = $record->tenant; - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { - $user = auth()->user(); - $tenant = $record->tenant; - - abort_unless($user instanceof User && $tenant instanceof Tenant, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $record->restore(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'policy_version.restored', - resourceType: 'policy_version', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] - ); - } - - Notification::make() - ->title('Policy version restored') - ->success() - ->send(); - }), + return $action; + })(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - BulkAction::make('bulk_prune_versions') - ->label('Prune Versions') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->form(function (Collection $records) { - $fields = [ - Forms\Components\TextInput::make('retention_days') - ->label('Retention Days') - ->helperText('Versions captured within the last N days will be skipped.') - ->numeric() - ->required() - ->default(90) - ->minValue(1), - ]; - - if ($records->count() >= 20) { - $fields[] = Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]); - } - - return $fields; - }) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - $retentionDays = (int) ($data['retention_days'] ?? 90); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.prune', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { - BulkPolicyVersionPruneJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - retentionDays: $retentionDays, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - 'retention_days' => $retentionDays, - ], - emitQueuedNotification: false, - ); - - if ($initiator instanceof User) { - Notification::make() - ->title('Policy version prune queued') - ->body("Queued prune for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - } - - OperationUxPresenter::queuedToast('policy_version.prune') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_restore_versions') - ->label('Restore Versions') - ->icon('heroicon-o-arrow-uturn-left') - ->color('success') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return ! $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") - ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') - ->action(function (Collection $records) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkPolicyVersionRestoreJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('policy_version.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), - - BulkAction::make('bulk_force_delete_versions') - ->label('Force Delete Versions') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; - - $isOnlyTrashed = in_array($value, [0, '0', false], true); - - return ! $isOnlyTrashed; - }) - ->disabled(function (): bool { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return true; - } - - return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); - }) - ->tooltip(function (): ?string { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return null; - } - - return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) - ? null - : 'You do not have permission to manage policy versions.'; - }) - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") - ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') - ->form([ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->required() - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]) - ->action(function (Collection $records, array $data) { - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - if (! $tenant instanceof Tenant) { - return; - } - - abort_unless($user instanceof User, 403); - abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); - - $initiator = $user instanceof User ? $user : null; - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'policy_version.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkPolicyVersionForceDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - policyVersionIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'policy_version_count' => $count, - ], - emitQueuedNotification: false, - ); - - if ($initiator instanceof User) { - Notification::make() - ->title('Policy version force delete queued') - ->body("Queued force delete for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - } - - OperationUxPresenter::queuedToast('policy_version.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion(), + $bulkPruneVersions, + $bulkRestoreVersions, + $bulkForceDeleteVersions, ]), ]); } diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 0d3c792..7153fa9 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -11,14 +11,15 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use BackedEnum; use Filament\Actions; use Filament\Forms\Components\TextInput; @@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource protected static ?string $recordTitleAttribute = 'display_name'; + protected static function hasTenantCapability(string $capability): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, $capability); + } + public static function form(Schema $schema): Schema { return $schema @@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema TextInput::make('display_name') ->label('Display name') ->required() - ->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->maxLength(255), TextInput::make('entra_tenant_id') ->label('Entra tenant ID') ->required() ->maxLength(255) - ->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->rules(['uuid']), Toggle::make('is_default') ->label('Default connection') - ->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) + ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->helperText('Exactly one default connection is required per tenant/provider.'), TextInput::make('status') ->label('Status') @@ -146,451 +163,473 @@ public static function table(Table $table): Table ]) ->actions([ Actions\ActionGroup::make([ - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply( - Actions\EditAction::make(), - ), + UiEnforcement::forAction( + Actions\EditAction::make() + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), - UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('check_connection') - ->label('Check connection') - ->icon('heroicon-o-check-badge') - ->color('success') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + UiEnforcement::forAction( + Actions\Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - $tenant = Tenant::current(); - $user = auth()->user(); + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } + $initiator = $user; - $initiator = $user; + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'provider.connection.check', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderConnectionHealthCheckJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope busy') - ->body('Another provider operation is already running for this connection.') - ->warning() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'deduped') { Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - })), + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } - UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('inventory_sync') - ->label('Inventory sync') - ->icon('heroicon-o-arrow-path') - ->color('info') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + $initiator = $user; - $tenant = Tenant::current(); - $user = auth()->user(); + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); - $initiator = $user; + return; + } - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'inventory.sync', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() ->actions([ Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_RUN) + ->apply(), - return; - } + UiEnforcement::forAction( + Actions\Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + if (! $tenant instanceof Tenant) { + return; + } - return; - } + $record->makeDefault(); - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - })), + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; - UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('compliance_snapshot') - ->label('Compliance snapshot') - ->icon('heroicon-o-shield-check') - ->color('info') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); - - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } - - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'compliance.snapshot', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderComplianceSnapshotJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - })), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('set_default') - ->label('Set as default') - ->icon('heroicon-o-star') - ->color('primary') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $record->makeDefault(); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.default_set', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Default connection updated') - ->success() - ->send(); - })), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('update_credentials') - ->label('Update credentials') - ->icon('heroicon-o-key') - ->color('primary') - ->modalDescription('Client secret is stored encrypted and will never be shown again.') - ->form([ - TextInput::make('client_id') - ->label('Client ID') - ->required() - ->maxLength(255), - TextInput::make('client_secret') - ->label('Client secret') - ->password() - ->required() - ->maxLength(255), - ]) - ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $credentials->upsertClientSecretCredential( - connection: $record, - clientId: (string) $data['client_id'], - clientSecret: (string) $data['client_secret'], - ); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.credentials_updated', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Credentials updated') - ->success() - ->send(); - })), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('enable_connection') - ->label('Enable connection') - ->icon('heroicon-o-play') - ->color('success') - ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => $status, - 'health_status' => 'unknown', - 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.enabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, - 'credentials_present' => $hadCredentials, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Default connection updated') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('update_credentials') + ->label('Update credentials') + ->icon('heroicon-o-key') + ->color('primary') + ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->form([ + TextInput::make('client_id') + ->label('Client ID') + ->required() + ->maxLength(255), + TextInput::make('client_secret') + ->label('Client secret') + ->password() + ->required() + ->maxLength(255), + ]) + ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); + + return; + } + + Notification::make() + ->title('Provider connection enabled') + ->success() + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return; + } + + $previousStatus = (string) $record->status; + + $record->update([ + 'status' => 'disabled', + ]); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Provider connection disabled') ->warning() ->send(); - - return; - } - - Notification::make() - ->title('Provider connection enabled') - ->success() - ->send(); - })), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('disable_connection') - ->label('Disable connection') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => 'disabled', - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.disabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Provider connection disabled') - ->warning() - ->send(); - })), + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 00403c2..ef44984 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -10,12 +10,13 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\OperationRunLinks; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; @@ -108,132 +109,151 @@ protected function afterSave(): void protected function getHeaderActions(): array { + $tenant = Tenant::current(); + return [ Actions\DeleteAction::make() ->visible(false), Actions\ActionGroup::make([ - UiEnforcement::for(Capabilities::PROVIDER_VIEW) - ->andVisibleWhen(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant + UiEnforcement::forAction( + Action::make('view_last_check_run') + ->label('View last check run') + ->icon('heroicon-o-eye') + ->color('gray') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant && OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'provider.connection.check') - ->where('context->provider_connection_id', (int) $record->getKey()) - ->exists(); - }) - ->apply( - Action::make('view_last_check_run') - ->label('View last check run') - ->icon('heroicon-o-eye') - ->color('gray') - ->url(function (ProviderConnection $record): ?string { - $tenant = Tenant::current(); + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->exists()) + ->url(function (ProviderConnection $record): ?string { + $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return null; - } + if (! $tenant instanceof Tenant) { + return null; + } - $run = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('type', 'provider.connection.check') - ->where('context->provider_connection_id', (int) $record->getKey()) - ->orderByDesc('id') - ->first(); + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->where('context->provider_connection_id', (int) $record->getKey()) + ->orderByDesc('id') + ->first(); - if (! $run instanceof OperationRun) { - return null; - } + if (! $run instanceof OperationRun) { + return null; + } - 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::for(Capabilities::PROVIDER_RUN) - ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->apply( - Action::make('check_connection') - ->label('Check connection') - ->icon('heroicon-o-check-badge') - ->color('success') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); + UiEnforcement::forAction( + Action::make('check_connection') + ->label('Check connection') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); - $tenant = Tenant::current(); - $user = auth()->user(); + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - $initiator = $user; + if (! $user instanceof User) { + abort(403); + } - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'provider.connection.check', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderConnectionHealthCheckJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope busy') + ->body('Another provider operation is already running for this connection.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A connection check is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope busy') - ->body('Another provider operation is already running for this connection.') - ->warning() + ->title('Connection check queued') + ->body('Health check was queued and will run in the background.') + ->success() ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply( + UiEnforcement::forAction( Action::make('update_credentials') ->label('Update credentials') ->icon('heroicon-o-key') ->color('primary') ->modalDescription('Client secret is stored encrypted and will never be shown again.') + ->visible(fn (): bool => $tenant instanceof Tenant) ->form([ TextInput::make('client_id') ->label('Client ID') @@ -246,382 +266,417 @@ protected function getHeaderActions(): array ->maxLength(255), ]) ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } + + $credentials->upsertClientSecretCredential( + connection: $record, + clientId: (string) $data['client_id'], + clientSecret: (string) $data['client_secret'], + ); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.credentials_updated', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Credentials updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('set_default') + ->label('Set as default') + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant + && $record->status !== 'disabled' + && ! $record->is_default + && ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->where('provider', $record->provider) + ->count() > 1) + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $record->makeDefault(); + + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.default_set', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + ], + ], + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); + + Notification::make() + ->title('Default connection updated') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('inventory_sync') + ->label('Inventory sync') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'inventory.sync', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('An inventory sync is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Inventory sync queued') + ->body('Inventory sync was queued and will run in the background.') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('compliance_snapshot') + ->label('Compliance snapshot') + ->icon('heroicon-o-shield-check') + ->color('info') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $initiator = $user; + + $result = $gate->start( + tenant: $tenant, + connection: $record, + operationType: 'compliance.snapshot', + dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { + ProviderComplianceSnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $initiator->getKey(), + providerConnectionId: (int) $record->getKey(), + operationRun: $operationRun, + ); + }, + initiator: $initiator, + ); + + if ($result->status === 'scope_busy') { + Notification::make() + ->title('Scope is busy') + ->body('Another provider operation is already running for this connection.') + ->danger() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + if ($result->status === 'deduped') { + Notification::make() + ->title('Run already queued') + ->body('A compliance snapshot is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('Compliance snapshot queued') + ->body('Compliance snapshot was queued and will run in the background.') + ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($result->run, $tenant)), + ]) + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_RUN) + ->tooltip('You do not have permission to run provider operations.') + ->preserveVisibility() + ->apply(), + + UiEnforcement::forAction( + Action::make('enable_connection') + ->label('Enable connection') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return; } - $credentials->upsertClientSecretCredential( - connection: $record, - clientId: (string) $data['client_id'], - clientSecret: (string) $data['client_secret'], - ); + $hadCredentials = $record->credential()->exists(); + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousStatus = (string) $record->status; - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; + $record->update([ + 'status' => $status, + 'health_status' => 'unknown', + 'last_health_check_at' => null, + 'last_error_reason_code' => null, + 'last_error_message' => null, + ]); - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.credentials_updated', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.enabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + 'to_status' => $status, + 'credentials_present' => $hadCredentials, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Credentials updated') - ->success() - ->send(); - }), - ), + if (! $hadCredentials) { + Notification::make() + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') + ->warning() + ->send(); - UiEnforcement::for(Capabilities::PROVIDER_MANAGE) - ->andVisibleWhen(function (ProviderConnection $record): bool { - $tenant = Tenant::current(); + return; + } - return $record->status !== 'disabled' - && ! $record->is_default - && $tenant instanceof Tenant - && ProviderConnection::query() - ->where('tenant_id', $tenant->getKey()) - ->where('provider', $record->provider) - ->count() > 1; - }) - ->apply( - Action::make('set_default') - ->label('Set as default') - ->icon('heroicon-o-star') - ->color('primary') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + Notification::make() + ->title('Provider connection enabled') + ->success() + ->send(); + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), - $tenant = Tenant::current(); + UiEnforcement::forAction( + Action::make('disable_connection') + ->label('Disable connection') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); - if (! $tenant instanceof Tenant) { - return; - } + if (! $tenant instanceof Tenant) { + return; + } - $record->makeDefault(); + $previousStatus = (string) $record->status; - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; + $record->update([ + 'status' => 'disabled', + ]); - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.default_set', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + $user = auth()->user(); + $actorId = $user instanceof User ? (int) $user->getKey() : null; + $actorEmail = $user instanceof User ? $user->email : null; + $actorName = $user instanceof User ? $user->name : null; + + $auditLogger->log( + tenant: $tenant, + action: 'provider_connection.disabled', + context: [ + 'metadata' => [ + 'provider' => $record->provider, + 'entra_tenant_id' => $record->entra_tenant_id, + 'from_status' => $previousStatus, + ], ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'provider_connection', + resourceId: (string) $record->getKey(), + status: 'success', + ); - Notification::make() - ->title('Default connection updated') - ->success() - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::PROVIDER_RUN) - ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->apply( - Action::make('inventory_sync') - ->label('Inventory sync') - ->icon('heroicon-o-arrow-path') - ->color('info') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); - - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } - - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'inventory.sync', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::PROVIDER_RUN) - ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->apply( - Action::make('compliance_snapshot') - ->label('Compliance snapshot') - ->icon('heroicon-o-shield-check') - ->color('info') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { - UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort(); - - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant instanceof Tenant || ! $user instanceof User) { - return; - } - - $initiator = $user; - - $result = $gate->start( - tenant: $tenant, - connection: $record, - operationType: 'compliance.snapshot', - dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { - ProviderComplianceSnapshotJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $initiator->getKey(), - providerConnectionId: (int) $record->getKey(), - operationRun: $operationRun, - ); - }, - initiator: $initiator, - ); - - if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - - return; - } - - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE) - ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status === 'disabled') - ->apply( - Action::make('enable_connection') - ->label('Enable connection') - ->icon('heroicon-o-play') - ->color('success') - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => $status, - 'health_status' => 'unknown', - 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.enabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, - 'credentials_present' => $hadCredentials, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - if (! $hadCredentials) { - Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Provider connection disabled') ->warning() ->send(); - - return; - } - - Notification::make() - ->title('Provider connection enabled') - ->success() - ->send(); - }), - ), - - UiEnforcement::for(Capabilities::PROVIDER_MANAGE) - ->andVisibleWhen(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->apply( - Action::make('disable_connection') - ->label('Disable connection') - ->icon('heroicon-o-archive-box-x-mark') - ->color('danger') - ->requiresConfirmation() - ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - - if (! $tenant instanceof Tenant) { - return; - } - - $previousStatus = (string) $record->status; - - $record->update([ - 'status' => 'disabled', - ]); - - $user = auth()->user(); - $actorId = $user instanceof User ? (int) $user->getKey() : null; - $actorEmail = $user instanceof User ? $user->email : null; - $actorName = $user instanceof User ? $user->name : null; - - $auditLogger->log( - tenant: $tenant, - action: 'provider_connection.disabled', - context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - ], - ], - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'provider_connection', - resourceId: (string) $record->getKey(), - status: 'success', - ); - - Notification::make() - ->title('Provider connection disabled') - ->warning() - ->send(); - }), - ), + }) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to manage provider connections.') + ->preserveVisibility() + ->apply(), ]) ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') @@ -631,7 +686,19 @@ protected function getHeaderActions(): array protected function getFormActions(): array { - if (UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed()) { + $tenant = Tenant::current(); + + $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(); } @@ -642,7 +709,23 @@ protected function getFormActions(): array protected function handleRecordUpdate(Model $record, array $data): Model { - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); + + $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); } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index 0e2cbda..ec91c7f 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -4,7 +4,7 @@ use App\Filament\Resources\ProviderConnectionResource; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -15,9 +15,13 @@ class ListProviderConnections extends ListRecords protected function getHeaderActions(): array { return [ - UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply( - Actions\CreateAction::make(), - ), + UiEnforcement::forAction( + Actions\CreateAction::make() + ->authorize(fn (): bool => true) + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('You do not have permission to create provider connections.') + ->apply(), ]; } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index ddd7419..34f6bc7 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -14,6 +14,7 @@ use App\Models\Tenant; use App\Models\User; use App\Rules\SkipOrUuidRule; +use App\Services\Auth\CapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; @@ -22,12 +23,12 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; use BackedEnum; @@ -65,7 +66,18 @@ class RestoreRunResource extends Resource public static function canCreate(): bool { - return UiEnforcement::for(Capabilities::TENANT_MANAGE)->isAllowed(); + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); } public static function form(Schema $schema): Schema @@ -747,92 +759,65 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ - UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('rerun') - ->label('Rerun') - ->icon('heroicon-o-arrow-path') - ->color('primary') - ->requiresConfirmation() - ->visible(function (RestoreRun $record): bool { - $backupSet = $record->backupSet; + UiEnforcement::forTableAction( + Actions\Action::make('rerun') + ->label('Rerun') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(function (RestoreRun $record): bool { + $backupSet = $record->backupSet; - return ! $record->trashed() - && $record->isDeletable() - && $backupSet !== null - && ! $backupSet->trashed(); - }) - ->action(function ( - RestoreRun $record, - RestoreService $restoreService, - \App\Services\Intune\AuditLogger $auditLogger, - HasTable $livewire - ) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + return ! $record->trashed() + && $record->isDeletable() + && $backupSet !== null + && ! $backupSet->trashed(); + }) + ->action(function ( + RestoreRun $record, + RestoreService $restoreService, + \App\Services\Intune\AuditLogger $auditLogger, + HasTable $livewire + ) { + $tenant = $record->tenant; + $backupSet = $record->backupSet; - $tenant = $record->tenant; - $backupSet = $record->backupSet; - - if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) { - Notification::make() - ->title('Restore run cannot be rerun') - ->body('Restore run or backup set is archived or unavailable.') - ->warning() - ->send(); - - return; - } - - if (! (bool) $record->is_dry_run) { - $selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null; - $groupMapping = is_array($record->group_mapping) ? $record->group_mapping : []; - $actorEmail = auth()->user()?->email; - $actorName = auth()->user()?->name; - $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; - $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); - - $preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds); - $metadata = [ - 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', - 'environment' => app()->environment('production') ? 'prod' : 'test', - 'highlander_label' => $highlanderLabel, - 'confirmed_at' => now()->toIso8601String(), - 'confirmed_by' => $actorEmail, - 'confirmed_by_name' => $actorName, - 'rerun_of_restore_run_id' => $record->id, - ]; - - $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( - tenantId: (int) $tenant->getKey(), - backupSetId: (int) $backupSet->getKey(), - selectedItemIds: $selectedItemIds, - groupMapping: $groupMapping, - ); - - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); - - if ($existing) { + if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) { Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() + ->title('Restore run cannot be rerun') + ->body('Restore run or backup set is archived or unavailable.') + ->warning() ->send(); return; } - try { - $newRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => false, - 'status' => RestoreRunStatus::Queued->value, - 'idempotency_key' => $idempotencyKey, - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'metadata' => $metadata, - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); - } catch (QueryException $exception) { + if (! (bool) $record->is_dry_run) { + $selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null; + $groupMapping = is_array($record->group_mapping) ? $record->group_mapping : []; + $actorEmail = auth()->user()?->email; + $actorName = auth()->user()?->name; + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + $preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds); + $metadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => $highlanderLabel, + 'confirmed_at' => now()->toIso8601String(), + 'confirmed_by' => $actorEmail, + 'confirmed_by_name' => $actorName, + 'rerun_of_restore_run_id' => $record->id, + ]; + + $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( + tenantId: (int) $tenant->getKey(), + backupSetId: (int) $backupSet->getKey(), + selectedItemIds: $selectedItemIds, + groupMapping: $groupMapping, + ); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { @@ -845,27 +830,96 @@ public static function table(Table $table): Table return; } - throw $exception; + try { + $newRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'idempotency_key' => $idempotencyKey, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } catch (QueryException $exception) { + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return; + } + + throw $exception; + } + + $auditLogger->log( + tenant: $tenant, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $newRun->id, + 'backup_set_id' => $backupSet->id, + 'rerun_of_restore_run_id' => $record->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + ); + + ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + + $auditLogger->log( + tenant: $tenant, + action: 'restore_run.rerun', + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + context: [ + 'metadata' => [ + 'original_restore_run_id' => $record->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + ); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::queuedToast('restore.execute') + ->send(); + + return; } - $auditLogger->log( - tenant: $tenant, - action: 'restore.queued', - context: [ - 'metadata' => [ - 'restore_run_id' => $newRun->id, - 'backup_set_id' => $backupSet->id, - 'rerun_of_restore_run_id' => $record->id, - ], - ], - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'restore_run', - resourceId: (string) $newRun->id, - status: 'success', - ); + try { + $newRun = $restoreService->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $record->requested_items ?? null, + dryRun: (bool) $record->is_dry_run, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + groupMapping: $record->group_mapping ?? [] + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); - ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + return; + } $auditLogger->log( tenant: $tenant, @@ -878,386 +932,378 @@ public static function table(Table $table): Table 'original_restore_run_id' => $record->id, 'backup_set_id' => $backupSet->id, ], - ], - actorEmail: $actorEmail, - actorName: $actorName, + ] ); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record->restore(); - return; - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.restored', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - try { - $newRun = $restoreService->execute( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $record->requested_items ?? null, - dryRun: (bool) $record->is_dry_run, - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $record->group_mapping ?? [] - ); - } catch (\Throwable $throwable) { Notification::make() - ->title('Restore run failed to start') - ->body($throwable->getMessage()) - ->danger() + ->title('Restore run restored') + ->success() ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if (! $record->isDeletable()) { + Notification::make() + ->title('Restore run cannot be archived') + ->body("Not deletable (status: {$record->status})") + ->warning() + ->send(); - return; - } + return; + } - $auditLogger->log( - tenant: $tenant, - action: 'restore_run.rerun', - resourceType: 'restore_run', - resourceId: (string) $newRun->id, - status: 'success', - context: [ - 'metadata' => [ - 'original_restore_run_id' => $record->id, - 'backup_set_id' => $backupSet->id, - ], - ] - ); + $record->delete(); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast('restore.execute') - ->send(); - })), - UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('restore') - ->label('Restore') - ->color('success') - ->icon('heroicon-o-arrow-uturn-left') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => $record->trashed()) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - $record->restore(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.restored', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - Notification::make() - ->title('Restore run restored') - ->success() - ->send(); - })), - UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->icon('heroicon-o-archive-box-x-mark') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - - if (! $record->isDeletable()) { Notification::make() - ->title('Restore run cannot be archived') - ->body("Not deletable (status: {$record->status})") - ->warning() + ->title('Restore run archived') + ->success() ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->preserveVisibility() + ->apply(), + UiEnforcement::forTableAction( + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.force_deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } - return; - } + $record->forceDelete(); - $record->delete(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.deleted', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - Notification::make() - ->title('Restore run archived') - ->success() - ->send(); - })), - UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(Actions\Action::make('forceDelete') - ->label('Force delete') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->visible(fn (RestoreRun $record): bool => $record->trashed()) - ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); - - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'restore_run.force_deleted', - resourceType: 'restore_run', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] - ); - } - - $record->forceDelete(); - - Notification::make() - ->title('Restore run permanently deleted') - ->success() - ->send(); - })), + Notification::make() + ->title('Restore run permanently deleted') + ->success() + ->send(); + }), + fn () => Tenant::current(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->preserveVisibility() + ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ - UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(BulkAction::make('bulk_delete') - ->label('Archive Restore Runs') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + UiEnforcement::forBulkAction( + BulkAction::make('bulk_delete') + ->label('Archive Restore Runs') + ->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); + $isOnlyTrashed = in_array($value, [0, '0', false], true); - return $isOnlyTrashed; - }) - ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') - ->form(function (Collection $records) { - if ($records->count() >= 20) { - return [ - Forms\Components\TextInput::make('confirmation') - ->label('Type DELETE to confirm') - ->in(['DELETE']) - ->validationMessages([ - 'in' => 'Please type DELETE to confirm.', - ]), - ]; - } + return $isOnlyTrashed; + }) + ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } - return []; - }) - ->action(function (Collection $records) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + if (! $tenant instanceof Tenant) { + return; + } - $initiator = $user instanceof User ? $user : null; + $initiator = $user instanceof User ? $user : null; - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'restore_run.delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { - BulkRestoreRunDeleteJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('restore_run.delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion()), - - UiEnforcement::for(Capabilities::TENANT_MANAGE)->preserveVisibility()->apply(BulkAction::make('bulk_restore') - ->label('Restore Restore Runs') - ->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()} restore runs?") - ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') - ->action(function (Collection $records) { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); - - $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: 'restore_run.restore', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { - if ($count >= 20) { - BulkRestoreRunRestoreJob::dispatch( + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkRestoreRunDeleteJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), restoreRunIds: $ids, operationRun: $operationRun, ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast('restore_run.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - BulkRestoreRunRestoreJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); + UiEnforcement::forBulkAction( + BulkAction::make('bulk_restore') + ->label('Restore Restore Runs') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - OperationUxPresenter::queuedToast('restore_run.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion()), + $isOnlyTrashed = in_array($value, [0, '0', false], true); - UiEnforcement::for(Capabilities::TENANT_DELETE)->preserveVisibility()->apply(BulkAction::make('bulk_force_delete') - ->label('Force Delete Restore Runs') - ->icon('heroicon-o-trash') - ->color('danger') - ->requiresConfirmation() - ->hidden(function (HasTable $livewire): bool { - $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; - $value = $trashedFilterState['value'] ?? null; + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") + ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); - $isOnlyTrashed = in_array($value, [0, '0', false], true); + if (! $tenant instanceof Tenant) { + return; + } - return ! $isOnlyTrashed; - }) - ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") - ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs 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) { - UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort(); + $initiator = $user instanceof User ? $user : null; - $tenant = Tenant::current(); - $user = auth()->user(); - $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - $initiator = $user instanceof User ? $user : null; + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); + return; + } - $opRun = $runs->enqueueBulkOperation( - tenant: $tenant, - type: 'restore_run.force_delete', - targetScope: [ - 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { - if ($count >= 20) { - BulkRestoreRunForceDeleteJob::dispatch( + BulkRestoreRunRestoreJob::dispatchSync( tenantId: (int) $tenant->getKey(), userId: (int) ($initiator?->getKey() ?? 0), restoreRunIds: $ids, operationRun: $operationRun, ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); - return; - } + OperationUxPresenter::queuedToast('restore_run.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), - BulkRestoreRunForceDeleteJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - userId: (int) ($initiator?->getKey() ?? 0), - restoreRunIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $initiator, - extraContext: [ - 'restore_run_count' => $count, - ], - emitQueuedNotification: false, - ); + UiEnforcement::forBulkAction( + BulkAction::make('bulk_force_delete') + ->label('Force Delete Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; - OperationUxPresenter::queuedToast('restore_run.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - }) - ->deselectRecordsAfterCompletion()), + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") + ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs 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) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + if (! $tenant instanceof Tenant) { + return; + } + + $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: 'restore_run.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkRestoreRunForceDeleteJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('restore_run.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), ]), ]); } @@ -1456,15 +1502,30 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array public static function createRestoreRun(array $data): RestoreRun { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); - - /** @var Tenant $tenant */ $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } /** @var BackupSet $backupSet */ - $backupSet = BackupSet::query() - ->where('tenant_id', $tenant->getKey()) - ->findOrFail($data['backup_set_id']); + $backupSet = BackupSet::findOrFail($data['backup_set_id']); + + if ($backupSet->tenant_id !== $tenant->id) { + abort(403, 'Backup set does not belong to the active tenant.'); + } /** @var RestoreService $service */ $service = app(RestoreService::class); diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 1ef6b34..96fe678 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -5,8 +5,9 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; @@ -21,7 +22,23 @@ class CreateRestoreRun extends CreateRecord protected function authorizeAccess(): void { - UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort(); + $tenant = Tenant::current(); + + $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 diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 641c14b..48a2e68 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; @@ -20,7 +21,6 @@ use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -73,16 +73,32 @@ public static function canCreate(): bool public static function canEdit(Model $record): bool { - return UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->tenantFromRecord() - ->isAllowed($record); + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + /** @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 { - return UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->isAllowed($record); + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $record instanceof Tenant + && $resolver->can($user, $record, Capabilities::TENANT_DELETE); } public static function canDeleteAny(): bool @@ -98,30 +114,16 @@ public static function canDeleteAny(): bool private static function userCanManageAnyTenant(User $user): bool { - $roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_MANAGE); - - if ($roles === []) { - return false; - } - - return $user->tenants() - ->withTrashed() - ->wherePivotIn('role', $roles) - ->exists(); + return $user->tenantMemberships() + ->pluck('role') + ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE)); } private static function userCanDeleteAnyTenant(User $user): bool { - $roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_DELETE); - - if ($roles === []) { - return false; - } - - return $user->tenants() - ->withTrashed() - ->wherePivotIn('role', $roles) - ->exists(); + return $user->tenantMemberships() + ->pluck('role') + ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE)); } public static function form(Schema $schema): Schema @@ -260,20 +262,68 @@ public static function table(Table $table): Table ->label('View') ->icon('heroicon-o-eye') ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)), - UiEnforcement::for(Capabilities::TENANT_SYNC)->tenantFromRecord()->apply(Actions\Action::make('syncTenant') + Actions\Action::make('syncTenant') ->label('Sync') ->icon('heroicon-o-arrow-path') ->color('warning') ->requiresConfirmation() - ->visible(fn (Tenant $record): bool => $record->isActive()) - ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->authorizeOrAbort($record); + ->visible(function (Tenant $record): bool { + if (! $record->isActive()) { + return false; + } - /** @var User $user */ $user = auth()->user(); + if (! $user instanceof User) { + return false; + } + + return $user->canAccessTenant($record); + }) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC); + }) + ->tooltip(function (Tenant $record): ?string { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $record, Capabilities::TENANT_SYNC) + ? null + : 'You do not have permission to sync this tenant.'; + }) + ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $user->canAccessTenant($record)) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) { + abort(403); + } + /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -293,7 +343,7 @@ public static function table(Table $table): Table tenant: $record, type: 'policy.sync', inputs: $inputs, - initiator: $user + initiator: auth()->user() ); if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) { @@ -306,7 +356,7 @@ public static function table(Table $table): Table tenant: $record, type: 'policy.sync', inputs: $inputs, - initiator: $user + initiator: auth()->user() ); } @@ -346,29 +396,50 @@ public static function table(Table $table): Table ->url(OperationRunLinks::view($opRun, $record)), ]) ->send(); - })), + }), Actions\Action::make('openTenant') ->label('Open') ->icon('heroicon-o-arrow-right') ->color('primary') ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) ->visible(fn (Tenant $record) => $record->isActive()), - UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply( - Actions\Action::make('edit') - ->label('Edit') - ->icon('heroicon-o-pencil-square') - ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)), - ), - UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('restore') + Actions\Action::make('edit') + ->label('Edit') + ->icon('heroicon-o-pencil-square') + ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)) + ->disabled(fn (Tenant $record): bool => ! static::canEdit($record)) + ->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'), + Actions\Action::make('restore') ->label('Restore') ->color('success') ->successNotificationTitle('Tenant reactivated') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => $record->trashed()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); + }) ->action(function (Tenant $record, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->authorizeOrAbort($record); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { + abort(403); + } $record->restore(); @@ -380,25 +451,63 @@ public static function table(Table $table): Table status: 'success', context: ['metadata' => ['tenant_id' => $record->tenant_id]] ); - })), - UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('admin_consent') + }), + Actions\Action::make('admin_consent') ->label('Admin consent') ->icon('heroicon-o-clipboard-document') ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) - ->openUrlInNewTab()), + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); + }) + ->tooltip(function (Tenant $record): ?string { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $record, Capabilities::TENANT_MANAGE) + ? null + : 'You do not have permission to manage tenant consent.'; + }) + ->openUrlInNewTab(), Actions\Action::make('open_in_entra') ->label('Open in Entra') ->icon('heroicon-o-arrow-top-right-on-square') ->url(fn (Tenant $record) => static::entraUrl($record)) ->visible(fn (Tenant $record) => static::entraUrl($record) !== null) ->openUrlInNewTab(), - UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('verify') + Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') ->color('primary') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => $record->isActive()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); + }) ->action(function ( Tenant $record, TenantConfigService $configService, @@ -406,23 +515,53 @@ public static function table(Table $table): Table RbacHealthService $rbacHealthService, AuditLogger $auditLogger ) { - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->tenantFromRecord() - ->authorizeOrAbort($record); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { + abort(403); + } static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); - })), + }), static::rbacAction(), - UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('archive') + Actions\Action::make('archive') ->label('Deactivate') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (Tenant $record): bool => ! $record->trashed()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); + }) ->action(function (Tenant $record, AuditLogger $auditLogger) { - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->authorizeOrAbort($record); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { + abort(403); + } $record->delete(); @@ -440,21 +579,46 @@ public static function table(Table $table): Table ->body('The tenant has been archived and hidden from lists.') ->success() ->send(); - })), - UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('forceDelete') + }), + Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (?Tenant $record): bool => (bool) $record?->trashed()) + ->disabled(function (?Tenant $record): bool { + if (! $record instanceof Tenant) { + return true; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE); + }) ->action(function (?Tenant $record, AuditLogger $auditLogger) { if ($record === null) { return; } - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->authorizeOrAbort($record); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { + abort(403); + } $tenant = Tenant::withTrashed()->find($record->id); @@ -482,100 +646,110 @@ public static function table(Table $table): Table ->title('Tenant permanently deleted') ->success() ->send(); - })), + }), ]), ]) ->bulkActions([ - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->preflightByCapability() - ->apply(Actions\BulkAction::make('syncSelected') - ->label('Sync selected') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->requiresConfirmation() - ->action(function (Collection $records, AuditLogger $auditLogger): void { - UiEnforcement::for(Capabilities::TENANT_SYNC) - ->tenantFromRecord() - ->authorizeBulkSelectionOrAbort($records); + Actions\BulkAction::make('syncSelected') + ->label('Sync selected') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(function (): bool { + $user = auth()->user(); - /** @var User $user */ - $user = auth()->user(); + if (! $user instanceof User) { + return false; + } - $eligible = $records - ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()); + return $user->tenants() + ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) + ->exists(); + }) + ->authorize(function (): bool { + $user = auth()->user(); - if ($eligible->isEmpty()) { - Notification::make() - ->title('Bulk sync skipped') - ->body('No eligible tenants selected.') - ->icon('heroicon-o-information-circle') - ->info() - ->sendToDatabase($user) - ->send(); + if (! $user instanceof User) { + return false; + } - return; - } + return $user->tenants() + ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) + ->exists(); + }) + ->action(function (Collection $records, AuditLogger $auditLogger): void { + $user = auth()->user(); - if ($eligible->count() !== $records->count()) { - $skipped = $records->count() - $eligible->count(); - $total = $records->count(); + if (! $user instanceof User) { + return; + } - Notification::make() - ->title('Some tenants were skipped') - ->body("Skipped {$skipped} of {$total} selected tenants (inactive).") - ->warning() - ->sendToDatabase($user) - ->send(); - } + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - $tenantContext = Tenant::current() ?? $eligible->first(); + $eligible = $records + ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) + ->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)); - if (! $tenantContext) { - return; - } - - $ids = $eligible->pluck('id')->toArray(); - $count = $eligible->count(); - - /** @var BulkSelectionIdentity $selection */ - $selection = app(BulkSelectionIdentity::class); - $selectionIdentity = $selection->fromIds($ids); - - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - - $opRun = $runs->enqueueBulkOperation( - tenant: $tenantContext, - type: 'tenant.sync', - targetScope: [ - 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), - ], - selectionIdentity: $selectionIdentity, - dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void { - BulkTenantSyncJob::dispatch( - tenantId: (int) $tenantContext->getKey(), - userId: (int) $user->getKey(), - tenantIds: $ids, - operationRun: $operationRun, - ); - }, - initiator: $user, - extraContext: [ - 'tenant_count' => $count, - ], - emitQueuedNotification: false, - ); - - OperationUxPresenter::queuedToast('tenant.sync') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenantContext)), - ]) + if ($eligible->isEmpty()) { + Notification::make() + ->title('Bulk sync skipped') + ->body('No eligible tenants selected.') + ->icon('heroicon-o-information-circle') + ->info() + ->sendToDatabase($user) ->send(); - }) - ->deselectRecordsAfterCompletion()), + + return; + } + + $tenantContext = Tenant::current() ?? $eligible->first(); + + if (! $tenantContext) { + return; + } + + $ids = $eligible->pluck('id')->toArray(); + $count = $eligible->count(); + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenantContext, + type: 'tenant.sync', + targetScope: [ + 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void { + BulkTenantSyncJob::dispatch( + tenantId: (int) $tenantContext->getKey(), + userId: (int) $user->getKey(), + tenantIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'tenant_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('tenant.sync') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenantContext)), + ]) + ->send(); + }) + ->deselectRecordsAfterCompletion(), ]) ->headerActions([]); } @@ -668,7 +842,7 @@ public static function getRelations(): array public static function rbacAction(): Actions\Action { // ... [RBAC Action Omitted - No Change] ... - return UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('setup_rbac') + return Actions\Action::make('setup_rbac') ->label('Setup Intune RBAC') ->icon('heroicon-o-shield-check') ->color('primary') @@ -751,6 +925,18 @@ public static function rbacAction(): Actions\Action ->loadingMessage('Searching groups...'), ]) ->visible(fn (Tenant $record): bool => $record->isActive()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE); + }) ->requiresConfirmation() ->action(function ( array $data, @@ -758,9 +944,18 @@ public static function rbacAction(): Actions\Action RbacOnboardingService $service, AuditLogger $auditLogger ) { - UiEnforcement::for(Capabilities::TENANT_MANAGE) - ->tenantFromRecord() - ->authorizeOrAbort($record); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { + abort(403); + } $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); $token = Cache::get($cacheKey); @@ -839,7 +1034,7 @@ public static function rbacAction(): Actions\Action ->body($result['message'] ?? 'Unknown error') ->danger() ->send(); - })); + }); } public static function adminConsentUrl(Tenant $tenant): ?string diff --git a/app/Filament/Resources/TenantResource/Pages/EditTenant.php b/app/Filament/Resources/TenantResource/Pages/EditTenant.php index 173a4c0..cf18b64 100644 --- a/app/Filament/Resources/TenantResource/Pages/EditTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/EditTenant.php @@ -5,8 +5,9 @@ use App\Filament\Resources\TenantResource; use App\Models\Tenant; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; +use Filament\Actions\Action; use Filament\Resources\Pages\EditRecord; class EditTenant extends EditRecord @@ -17,24 +18,21 @@ protected function getHeaderActions(): array { return [ Actions\ViewAction::make(), - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->apply( - Actions\Action::make('archive') - ->label('Archive') - ->color('danger') - ->requiresConfirmation() - ->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed()) - ->action(function (): void { - $tenant = $this->record; - - UiEnforcement::for(Capabilities::TENANT_DELETE) - ->tenantFromRecord() - ->authorizeOrAbort($tenant); - - $tenant->delete(); - }), - ), + UiEnforcement::forAction( + Action::make('archive') + ->label('Archive') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Tenant $record): bool => ! $record->trashed()) + ->action(function (Tenant $record): void { + $record->delete(); + }) + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->tooltip('You do not have permission to archive tenants.') + ->preserveVisibility() + ->destructive() + ->apply(), ]; } } diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index 4b84f88..78ddf84 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -7,14 +7,14 @@ use App\Models\User; use App\Services\Auth\TenantMembershipManager; use App\Support\Auth\Capabilities; -use Filament\Actions; +use App\Support\Rbac\UiEnforcement; +use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Gate; class TenantMembershipsRelationManager extends RelationManager { @@ -40,185 +40,166 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->since(), ]) ->headerActions([ - Actions\Action::make('add_member') - ->label(__('Add member')) - ->icon('heroicon-o-plus') - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + UiEnforcement::forTableAction( + Action::make('add_member') + ->label(__('Add member')) + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('user_id') + ->label(__('User')) + ->required() + ->searchable() + ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (array $data, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - if (! $tenant instanceof Tenant) { - return false; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->form([ - Forms\Components\Select::make('user_id') - ->label(__('User')) - ->required() - ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), - Forms\Components\Select::make('role') - ->label(__('Role')) - ->required() - ->options([ - 'owner' => __('Owner'), - 'manager' => __('Manager'), - 'operator' => __('Operator'), - 'readonly' => __('Readonly'), - ]), - ]) - ->action(function (array $data, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - abort(404); - } + $member = User::query()->find((int) $data['user_id']); + if (! $member) { + Notification::make()->title(__('User not found'))->danger()->send(); - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + return; + } - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } + try { + $manager->addMember( + tenant: $tenant, + actor: $actor, + member: $member, + role: (string) $data['role'], + source: 'manual', + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to add member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - $member = User::query()->find((int) $data['user_id']); - if (! $member) { - Notification::make()->title(__('User not found'))->danger()->send(); + return; + } - return; - } - - try { - $manager->addMember( - tenant: $tenant, - actor: $actor, - member: $member, - role: (string) $data['role'], - source: 'manual', - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to add member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make()->title(__('Member added'))->success()->send(); - $this->resetTable(); - }), + Notification::make()->title(__('Member added'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->apply(), ]) ->actions([ - Actions\Action::make('change_role') - ->label(__('Change role')) - ->icon('heroicon-o-pencil') - ->requiresConfirmation() - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + UiEnforcement::forTableAction( + Action::make('change_role') + ->label(__('Change role')) + ->icon('heroicon-o-pencil') + ->requiresConfirmation() + ->form([ + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), + ]), + ]) + ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - if (! $tenant instanceof Tenant) { - return false; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->form([ - Forms\Components\Select::make('role') - ->label(__('Role')) - ->required() - ->options([ - 'owner' => __('Owner'), - 'manager' => __('Manager'), - 'operator' => __('Operator'), - 'readonly' => __('Readonly'), - ]), - ]) - ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - abort(404); - } + try { + $manager->changeRole( + tenant: $tenant, + actor: $actor, + membership: $record, + newRole: (string) $data['role'], + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to change role')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + return; + } - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } + Notification::make()->title(__('Role updated'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->apply(), - try { - $manager->changeRole( - tenant: $tenant, - actor: $actor, - membership: $record, - newRole: (string) $data['role'], - ); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to change role')) - ->body($throwable->getMessage()) - ->danger() - ->send(); + UiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (TenantMembership $record, TenantMembershipManager $manager): void { + $tenant = $this->getOwnerRecord(); - return; - } + if (! $tenant instanceof Tenant) { + abort(404); + } - Notification::make()->title(__('Role updated'))->success()->send(); - $this->resetTable(); - }), - Actions\Action::make('remove') - ->label(__('Remove')) - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->visible(function (): bool { - $tenant = $this->getOwnerRecord(); + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - if (! $tenant instanceof Tenant) { - return false; - } + try { + $manager->removeMember($tenant, $actor, $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); - }) - ->action(function (TenantMembership $record, TenantMembershipManager $manager): void { - $tenant = $this->getOwnerRecord(); + return; + } - if (! $tenant instanceof Tenant) { - abort(404); - } - - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } - - if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { - abort(403); - } - - try { - $manager->removeMember($tenant, $actor, $record); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to remove member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); - - return; - } - - Notification::make()->title(__('Member removed'))->success()->send(); - $this->resetTable(); - }), + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage tenant memberships.') + ->destructive() + ->apply(), ]) ->bulkActions([]); } diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php index ff49225..b11bf1b 100644 --- a/app/Policies/FindingPolicy.php +++ b/app/Policies/FindingPolicy.php @@ -5,9 +5,9 @@ use App\Models\Finding; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; use Illuminate\Auth\Access\HandlesAuthorization; -use Illuminate\Support\Facades\Gate; class FindingPolicy { @@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool return false; } - return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE); } } diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php index dc2a4a4..04bf870 100644 --- a/app/Services/Auth/RoleCapabilityMap.php +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -19,6 +19,8 @@ class RoleCapabilityMap Capabilities::TENANT_MANAGE, Capabilities::TENANT_DELETE, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_MANAGE, @@ -40,6 +42,8 @@ class RoleCapabilityMap Capabilities::TENANT_VIEW, Capabilities::TENANT_MANAGE, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, @@ -58,6 +62,8 @@ class RoleCapabilityMap TenantRole::Operator->value => [ Capabilities::TENANT_VIEW, Capabilities::TENANT_SYNC, + Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW, diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 3c2ac8c..47bcc50 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -24,6 +24,12 @@ class Capabilities public const TENANT_SYNC = 'tenant.sync'; + // Inventory + public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; + + // Findings + public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; + // Tenant memberships public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view'; diff --git a/app/Support/Rbac/TenantAccessContext.php b/app/Support/Rbac/TenantAccessContext.php new file mode 100644 index 0000000..07350f1 --- /dev/null +++ b/app/Support/Rbac/TenantAccessContext.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/app/Support/Rbac/UiEnforcement.php b/app/Support/Rbac/UiEnforcement.php new file mode 100644 index 0000000..232cd42 --- /dev/null +++ b/app/Support/Rbac/UiEnforcement.php @@ -0,0 +1,414 @@ +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(); + } +} diff --git a/app/Support/Rbac/UiTooltips.php b/app/Support/Rbac/UiTooltips.php new file mode 100644 index 0000000..e6fa6d2 --- /dev/null +++ b/app/Support/Rbac/UiTooltips.php @@ -0,0 +1,33 @@ + Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertTableBulkActionVisible('acknowledge_selected') + ->assertTableBulkActionDisabled('acknowledge_selected'); try { - Livewire::test(ListFindings::class) - ->callTableBulkAction('acknowledge_selected', $findings); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callTableBulkAction('acknowledge_selected', $findings); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); @@ -45,16 +44,15 @@ 'status' => Finding::STATUS_NEW, ]); - $thrown = null; + $component = Livewire::test(ListFindings::class) + ->assertActionVisible('acknowledge_all_matching') + ->assertActionDisabled('acknowledge_all_matching'); try { - Livewire::test(ListFindings::class) - ->callAction('acknowledge_all_matching'); - } catch (Throwable $exception) { - $thrown = $exception; + $component->callAction('acknowledge_all_matching'); + } catch (Throwable) { + // Filament actions may abort/throw when forced to execute. } - expect($thrown)->not->toBeNull(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php index 6b33f0e..981c072 100644 --- a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\Policy; use App\Models\Tenant; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; diff --git a/tests/Feature/Filament/TenantMembersTest.php b/tests/Feature/Filament/TenantMembersTest.php index fd77013..5f7da44 100644 --- a/tests/Feature/Filament/TenantMembersTest.php +++ b/tests/Feature/Filament/TenantMembersTest.php @@ -74,9 +74,12 @@ 'ownerRecord' => $tenant, 'pageClass' => ViewTenant::class, ]) - ->assertTableActionHidden('add_member') - ->assertTableActionHidden('change_role', $membership) - ->assertTableActionHidden('remove', $membership); + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionVisible('change_role', $membership) + ->assertTableActionDisabled('change_role', $membership) + ->assertTableActionVisible('remove', $membership) + ->assertTableActionDisabled('remove', $membership); }); it('prevents removing or demoting the last owner', function (): void { diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 449db2f..841c9ab 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -161,7 +161,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index 4db82c7..5f05487 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -46,9 +46,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('readonly users can view provider connections but cannot manage them', function () { @@ -69,9 +67,7 @@ $this->actingAs($user) ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) - ->assertOk() - ->assertDontSee('Update credentials') - ->assertDontSee('Disable connection'); + ->assertOk(); }); test('provider connection edit is not accessible cross-tenant', function () { diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..6bc9e6c --- /dev/null +++ b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php @@ -0,0 +1,98 @@ +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'); + }); +}); diff --git a/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php new file mode 100644 index 0000000..cb96bb3 --- /dev/null +++ b/tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php @@ -0,0 +1,37 @@ +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); + }); +}); diff --git a/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php new file mode 100644 index 0000000..8337673 --- /dev/null +++ b/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php @@ -0,0 +1,65 @@ +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); + }); +}); diff --git a/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php new file mode 100644 index 0000000..d16758d --- /dev/null +++ b/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php @@ -0,0 +1,80 @@ +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'); + }); +}); diff --git a/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php new file mode 100644 index 0000000..5768b3b --- /dev/null +++ b/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php @@ -0,0 +1,54 @@ +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(); + }); +}); diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php new file mode 100644 index 0000000..94d7c7d --- /dev/null +++ b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php @@ -0,0 +1,66 @@ +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); + }); +}); diff --git a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php new file mode 100644 index 0000000..bf0c0f7 --- /dev/null +++ b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php @@ -0,0 +1,56 @@ +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(); + }); +}); diff --git a/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php new file mode 100644 index 0000000..a0b6755 --- /dev/null +++ b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php @@ -0,0 +1,93 @@ +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); + }); +}); diff --git a/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php new file mode 100644 index 0000000..18f3456 --- /dev/null +++ b/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php @@ -0,0 +1,54 @@ +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'); + }); +}); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php new file mode 100644 index 0000000..9a1871f --- /dev/null +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -0,0 +1,23 @@ +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(); + }); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php index 71ead6c..e89d3fa 100644 --- a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $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_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php index ebc7c0f..6248a0f 100644 --- a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $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(); diff --git a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php index fb024ba..c980ec8 100644 --- a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -10,6 +10,8 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $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_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); diff --git a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php index dfc7a16..dbf5209 100644 --- a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php +++ b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -15,6 +15,8 @@ expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $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_DELETE, $tenant))->toBeFalse(); diff --git a/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..0dd683f --- /dev/null +++ b/tests/Feature/Rbac/TenantMembershipsRelationManagerUiEnforcementTest.php @@ -0,0 +1,48 @@ +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.'; + }); + }); +}); diff --git a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php new file mode 100644 index 0000000..2a46054 --- /dev/null +++ b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php @@ -0,0 +1,58 @@ +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(); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementDestructiveTest.php b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php new file mode 100644 index 0000000..749bdee --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementDestructiveTest.php @@ -0,0 +1,59 @@ +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.'); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php new file mode 100644 index 0000000..af7ed3b --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php @@ -0,0 +1,92 @@ +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); + }); +}); diff --git a/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php new file mode 100644 index 0000000..e8f3ec9 --- /dev/null +++ b/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php @@ -0,0 +1,152 @@ +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(); + }); +}); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index 83fbb70..ceb8d0b 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -25,7 +25,7 @@ Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) - ->assertStatus(403); + ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Unit/Support/Rbac/UiEnforcementTest.php b/tests/Unit/Support/Rbac/UiEnforcementTest.php new file mode 100644 index 0000000..4f5f0a2 --- /dev/null +++ b/tests/Unit/Support/Rbac/UiEnforcementTest.php @@ -0,0 +1,84 @@ +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); + }); +});