spec/066-rbac-ui-enforcement-helper-v2 #82

Merged
ahmido merged 4 commits from spec/066-rbac-ui-enforcement-helper-v2 into dev 2026-01-30 17:22:26 +00:00
62 changed files with 6281 additions and 4516 deletions
Showing only changes of commit c48c99a857 - Show all commits

4
.gitignore vendored
View File

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

View File

@ -1,17 +1,18 @@
<!--
Sync Impact Report
- Version change: 1.4.0 → 1.5.0
- Version change: 1.5.0 → 1.6.0
- Modified principles:
- Tenant Isolation is Non-negotiable (added deny-as-not-found requirement)
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
- Added sections:
- RBAC Standard (RBAC-001..RBAC-009)
- Removed sections: None
- RBAC & UI Enforcement Standards (RBAC-UX)
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present)
- N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None
-->
@ -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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
@ -21,7 +22,6 @@
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class DriftLanding extends Page
@ -175,7 +175,10 @@ public function mount(): void
}
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
$this->state = 'blocked';
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';

View File

@ -4,13 +4,13 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
class RegisterTenant extends BaseRegisterTenant
{
@ -33,8 +33,11 @@ public static function canView(): bool
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
}
@ -88,7 +91,9 @@ public function form(Schema $schema): Schema
*/
protected function handleRegistration(array $data): Model
{
abort_unless(static::canView(), 403);
if (! static::canView()) {
abort(403);
}
$tenant = Tenant::create($data);

View File

@ -11,6 +11,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\Auth\CapabilityResolver;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
@ -23,6 +24,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
@ -50,7 +52,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
@ -65,19 +66,32 @@ public static function canViewAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
return false;
}
@ -92,32 +106,64 @@ public static function canCreate(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
}
public static function canEdit(Model $record): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
}
public static function canDelete(Model $record): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
}
public static function canDeleteAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
}
public static function form(Schema $schema): Schema
@ -315,21 +361,22 @@ public static function table(Table $table): Table
])
->actions([
ActionGroup::make([
UiEnforcement::forAction(
Action::make('runNow')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
})
->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant, 403);
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
Notification::make()
->title('No tenant selected')
->danger()
->send();
return;
}
$user = auth()->user();
$userId = auth()->id();
@ -445,22 +492,26 @@ public static function table(Table $table): Table
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(),
UiEnforcement::forAction(
Action::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
})
->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant, 403);
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
Notification::make()
->title('No tenant selected')
->danger()
->send();
return;
}
$user = auth()->user();
$userId = auth()->id();
@ -576,40 +627,40 @@ public static function table(Table $table): Table
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(),
UiEnforcement::forAction(
EditAction::make()
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}),
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(),
UiEnforcement::forAction(
DeleteAction::make()
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}),
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_run_now')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
})
->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant, 403);
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
Notification::make()
->title('No tenant selected')
->danger()
->send();
return;
}
if ($records->isEmpty()) {
return;
@ -731,22 +782,26 @@ public static function table(Table $table): Table
if (count($createdRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
}),
})
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
})
->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant, 403);
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
Notification::make()
->title('No tenant selected')
->danger()
->send();
return;
}
if ($records->isEmpty()) {
return;
@ -868,14 +923,15 @@ public static function table(Table $table): Table
if (count($createdRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
}),
})
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(),
UiEnforcement::forBulkAction(
DeleteBulkAction::make('bulk_delete')
->visible(function (): bool {
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}),
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->apply(),
]),
]);
}

View File

@ -10,6 +10,7 @@
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
@ -19,11 +20,13 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -34,7 +37,6 @@
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class BackupSetResource extends Resource
@ -47,8 +49,18 @@ class BackupSetResource extends Resource
public static function canCreate(): bool
{
return ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant);
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public static function form(Schema $schema): Schema
@ -90,18 +102,15 @@ public static function table(Table $table): Table
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$tenant = Filament::getTenant();
$record->restore();
$record->items()->withTrashed()->restore();
@ -121,19 +130,20 @@ public static function table(Table $table): Table
->title('Backup set restored')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$tenant = Filament::getTenant();
$record->delete();
@ -152,19 +162,20 @@ public static function table(Table $table): Table
->title('Backup set archived')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
$tenant = Filament::getTenant();
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
@ -194,18 +205,21 @@ public static function table(Table $table): Table
->title('Backup set permanently deleted')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Backup Sets')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -240,8 +254,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -282,14 +294,16 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Backup Sets')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -310,8 +324,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -352,14 +364,16 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Backup Sets')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -395,8 +409,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -437,6 +449,9 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
]);
}

View File

@ -16,6 +16,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
@ -24,7 +25,6 @@
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class BackupItemsRelationManager extends RelationManager
{
@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void
public function table(Table $table): Table
{
$refreshTable = Actions\Action::make('refreshTable')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$this->resetTable();
});
$addPolicies = Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->tooltip('You do not have permission to add policies.')
->modalHeading('Add Policies')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (): View {
$backupSet = $this->getOwnerRecord();
return view('filament.modals.backup-set-policy-picker', [
'backupSetId' => $backupSet->getKey(),
]);
});
UiEnforcement::forAction($addPolicies)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to add policies.')
->apply();
$removeItem = Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(404);
}
$backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
});
UiEnforcement::forAction($removeItem)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to remove policies.')
->apply();
$bulkRemove = Actions\BulkAction::make('bulk_remove')
->label('Remove selected')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(404);
}
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) {
return;
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
});
UiEnforcement::forBulkAction($bulkRemove)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to remove policies.')
->apply();
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([
@ -125,29 +318,8 @@ public function table(Table $table): Table
])
->filters([])
->headerActions([
Actions\Action::make('refreshTable')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$this->resetTable();
}),
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)))
->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.')
->modalHeading('Add Policies')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (): View {
$backupSet = $this->getOwnerRecord();
return view('filament.modals.backup-set-policy-picker', [
'backupSetId' => $backupSet->getKey(),
]);
}),
$refreshTable,
$addPolicies,
])
->actions([
Actions\ActionGroup::make([
@ -164,174 +336,12 @@ public function table(Table $table): Table
})
->hidden(fn (BackupItem $record) => ! $record->policy_id)
->openUrlInNewTab(true),
Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(403);
}
$backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
$removeItem,
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
Actions\BulkActionGroup::make([
Actions\BulkAction::make('bulk_remove')
->label('Remove selected')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(403);
}
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) {
return;
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
$bulkRemove,
]),
]);
}

View File

@ -12,10 +12,10 @@
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroups extends ListRecords
{
@ -29,81 +29,19 @@ protected function getHeaderActions(): array
->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()),
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return true;
})
->disabled(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync groups.';
})
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::current();
if (! $tenant) {
abort(403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
$selectionKey = EntraGroupSelection::allGroupsV1();
// --- Phase 3: Canonical Operation Run Start ---
@ -182,7 +120,11 @@ protected function getHeaderActions(): array
])
->sendToDatabase($user)
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync groups.')
->apply(),
];
}
}

View File

@ -10,9 +10,10 @@
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroupSyncRuns extends ListRecords
{
@ -21,48 +22,19 @@ class ListEntraGroupSyncRuns extends ListRecords
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::current();
if (! $tenant) {
abort(403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
@ -106,7 +78,11 @@ protected function getHeaderActions(): array
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

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

View File

@ -4,15 +4,15 @@
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
class ListFindings extends ListRecords
{
@ -21,23 +21,12 @@ class ListFindings extends ListRecords
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('acknowledge_all_matching')
->label('Acknowledge all matching')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->authorize(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || ! $user instanceof User) {
return false;
}
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
return $user->can('update', $probe);
})
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
->modalDescription(function (): string {
$count = $this->getAllMatchingCount();
@ -62,13 +51,6 @@ protected function getHeaderActions(): array
];
})
->action(function (array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || ! $user instanceof User) {
return;
}
$query = $this->buildAllMatchingQuery();
$count = (clone $query)->count();
@ -82,15 +64,10 @@ protected function getHeaderActions(): array
return;
}
$firstRecord = (clone $query)->first();
if ($firstRecord instanceof Finding) {
Gate::authorize('update', $firstRecord);
}
$updated = $query->update([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
'acknowledged_by_user_id' => auth()->id(),
]);
$this->deselectAllTableRecords();
@ -101,21 +78,26 @@ protected function getHeaderActions(): array
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
protected function buildAllMatchingQuery(): Builder
{
$tenant = Tenant::current();
$query = Finding::query();
if (! $tenant) {
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
if (! is_numeric($tenantId)) {
return $query->whereRaw('1 = 0');
}
$query->where('tenant_id', $tenant->getKey());
$query->where('tenant_id', (int) $tenantId);
$query->where('status', Finding::STATUS_NEW);

View File

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

View File

@ -16,6 +16,8 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
use Filament\Forms\Components\Hidden;
@ -24,7 +26,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Size;
use Illuminate\Support\Facades\Gate;
class ListInventoryItems extends ListRecords
{
@ -40,6 +41,7 @@ protected function getHeaderWidgets(): array
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('run_inventory_sync')
->label('Run Inventory Sync')
->icon('heroicon-o-arrow-path')
@ -118,51 +120,12 @@ protected function getHeaderActions(): array
return $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to start inventory sync.';
})
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403, 'Not allowed');
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$requestedTenantId = $data['tenant_id'] ?? null;
@ -172,7 +135,7 @@ protected function getHeaderActions(): array
->danger()
->send();
abort(403, 'Not allowed');
return;
}
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
@ -277,7 +240,12 @@ protected function getHeaderActions(): array
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -6,6 +6,8 @@
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -21,7 +23,6 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class InventorySyncRunResource extends Resource
@ -41,20 +42,31 @@ class InventorySyncRunResource extends Resource
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
return false;
}

View File

@ -22,6 +22,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -41,7 +42,6 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class PolicyResource extends Resource
@ -363,19 +363,14 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
UiEnforcement::forTableAction(
Actions\Action::make('ignore')
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (Policy $record, HasTable $livewire) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
->action(function (Policy $record): void {
$record->ignore();
Notification::make()
@ -383,19 +378,20 @@ public static function table(Table $table): Table
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to ignore policies.')
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (Policy $record) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
->action(function (Policy $record): void {
$record->unignore();
Notification::make()
@ -403,29 +399,19 @@ public static function table(Table $table): Table
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to restore policies.')
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('sync')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (Policy $record): bool {
if ($record->ignored_at !== null) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->action(function (Policy $record, HasTable $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
@ -438,10 +424,6 @@ public static function table(Table $table): Table
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
@ -479,28 +461,34 @@ public static function table(Table $table): Table
])
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_SYNC)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data) {
->action(function (Policy $record, array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$ids = [(int) $record->getKey()];
/** @var BulkSelectionIdentity $selection */
@ -543,10 +531,16 @@ public static function table(Table $table): Table
])
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Ignore Policies')
->icon('heroicon-o-trash')
@ -558,16 +552,6 @@ public static function table(Table $table): Table
return $value === 'ignored';
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
})
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
@ -583,18 +567,16 @@ public static function table(Table $table): Table
return [];
})
->action(function (Collection $records, array $data) {
->action(function (Collection $records): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $user instanceof User) {
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
@ -641,7 +623,11 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
@ -653,28 +639,20 @@ public static function table(Table $table): Table
return ! in_array($value, [null, 'ignored'], true);
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
})
->action(function (Collection $records, HasTable $livewire) {
->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
@ -739,28 +717,17 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_sync')
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
return true;
}
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
@ -779,10 +746,6 @@ public static function table(Table $table): Table
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
$ids = $records
->pluck('id')
->map(static fn ($id): int => (int) $id)
@ -829,38 +792,34 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_SYNC)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
})
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data) {
->action(function (Collection $records, array $data): void {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
@ -926,6 +885,9 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
]),
]);
}

View File

@ -11,10 +11,10 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListPolicies extends ListRecords
{
@ -23,61 +23,17 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
$tenant = Tenant::current();
return ! ($user instanceof User
&& $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
})
->tooltip(function (): ?string {
$user = auth()->user();
$tenant = Tenant::current();
if (! ($user instanceof User && $tenant instanceof Tenant)) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync policies.';
})
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $tenant instanceof Tenant) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$requestedTypes = array_map(
@ -125,7 +81,12 @@ protected function getHeaderActions(): array
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->destructive()
->apply(),
];
}
}

View File

@ -5,17 +5,20 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Gate;
class VersionsRelationManager extends RelationManager
{
@ -23,29 +26,10 @@ class VersionsRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->filters([])
->headerActions([])
->actions([
Actions\Action::make('restore_to_intune')
$restoreToIntune = Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
@ -56,8 +40,16 @@ public function table(Table $table): Table
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()
->title('Missing tenant or user context.')
->danger()
->send();
return;
}
if ($record->tenant_id !== $tenant->id) {
Notification::make()
@ -73,8 +65,8 @@ public function table(Table $table): Table
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
actorEmail: $user->email,
actorName: $user->name,
);
} catch (\Throwable $throwable) {
Notification::make()
@ -92,7 +84,74 @@ public function table(Table $table): Table
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
}),
});
UiEnforcement::forAction($restoreToIntune)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
$restoreToIntune
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true;
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
})
->tooltip(function (PolicyVersion $record): ?string {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return UiTooltips::INSUFFICIENT_PERMISSION;
}
return null;
});
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->filters([])
->headerActions([])
->actions([
$restoreToIntune,
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),

View File

@ -11,6 +11,7 @@
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff;
@ -21,6 +22,7 @@
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions;
@ -39,7 +41,6 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class PolicyVersionResource extends Resource
@ -183,6 +184,294 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.prune',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
BulkPolicyVersionPruneJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
retentionDays: $retentionDays,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
'retention_days' => $retentionDays,
],
emitQueuedNotification: false,
);
Notification::make()
->title('Policy version prune queued')
->body("Queued prune for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.prune')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkPruneVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkRestoreVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
Notification::make()
->title('Policy version force delete queued')
->body("Queued force delete for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion();
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $table
->columns([
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
@ -208,14 +497,64 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard')
(function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only'
|| ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->visible(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant);
})
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return true;
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
})
->tooltip(function (PolicyVersion $record): ?string {
if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return 'You do not have permission to create restore runs.';
}
@ -225,14 +564,34 @@ public static function table(Table $table): Table
return null;
})
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
if (($record->metadata['source'] ?? null) === 'metadata_only') {
Notification::make()
->title('Restore disabled for metadata-only snapshot')
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
->warning()
->send();
return;
}
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
@ -312,41 +671,24 @@ public static function table(Table $table): Table
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
}),
Actions\Action::make('archive')
});
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => ! $record->trashed())
->disabled(function (PolicyVersion $record): bool {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (PolicyVersion $record): ?string {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
$record->delete();
@ -365,41 +707,30 @@ public static function table(Table $table): Table
->title('Policy version archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->disabled(function (PolicyVersion $record): bool {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (PolicyVersion $record): ?string {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
if ($record->tenant) {
$auditLogger->log(
@ -418,42 +749,31 @@ public static function table(Table $table): Table
->title('Policy version permanently deleted')
->success()
->send();
}),
});
Actions\Action::make('restore')
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->disabled(function (PolicyVersion $record): bool {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (PolicyVersion $record): ?string {
$user = auth()->user();
$tenant = $record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$user = auth()->user();
$tenant = $record->tenant;
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(403);
}
$record->restore();
@ -472,350 +792,23 @@ public static function table(Table $table): Table
->title('Policy version restored')
->success()
->send();
}),
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
if (! $tenant instanceof Tenant) {
return;
}
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.prune',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
BulkPolicyVersionPruneJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
retentionDays: $retentionDays,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
'retention_days' => $retentionDays,
],
emitQueuedNotification: false,
);
if ($initiator instanceof User) {
Notification::make()
->title('Policy version prune queued')
->body("Queued prune for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
}
OperationUxPresenter::queuedToast('policy_version.prune')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
? null
: 'You do not have permission to manage policy versions.';
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
}
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
if ($initiator instanceof User) {
Notification::make()
->title('Policy version force delete queued')
->body("Queued force delete for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator);
}
OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
$bulkPruneVersions,
$bulkRestoreVersions,
$bulkForceDeleteVersions,
]),
]);
}

View File

@ -11,6 +11,7 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
@ -18,6 +19,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
@ -29,7 +31,6 @@
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class ProviderConnectionResource extends Resource
@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource
protected static ?string $recordTitleAttribute = 'display_name';
protected static function hasTenantCapability(string $capability): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, $capability);
}
public static function form(Schema $schema): Schema
{
return $schema
@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema
TextInput::make('display_name')
->label('Display name')
->required()
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->required()
->maxLength(255)
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Toggle::make('is_default')
->label('Default connection')
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->helperText('Exactly one default connection is required per tenant/provider.'),
TextInput::make('status')
->label('Status')
@ -146,55 +163,26 @@ public static function table(Table $table): Table
])
->actions([
Actions\ActionGroup::make([
Actions\EditAction::make(),
UiEnforcement::forAction(
Actions\EditAction::make()
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
->color('success')
->visible(function (ProviderConnection $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$initiator = $user;
$result = $gate->start(
@ -252,55 +240,26 @@ public static function table(Table $table): Table
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(function (ProviderConnection $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$initiator = $user;
$result = $gate->start(
@ -358,55 +317,26 @@ public static function table(Table $table): Table
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(function (ProviderConnection $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$initiator = $user;
$result = $gate->start(
@ -464,19 +394,24 @@ public static function table(Table $table): Table
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
&& $record->status !== 'disabled'
&& ! $record->is_default)
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$record->makeDefault();
@ -506,14 +441,18 @@ public static function table(Table $table): Table
->title('Default connection updated')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->form([
TextInput::make('client_id')
->label('Client ID')
@ -528,7 +467,9 @@ public static function table(Table $table): Table
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$credentials->upsertClientSecretCredential(
connection: $record,
@ -562,18 +503,23 @@ public static function table(Table $table): Table
->title('Credentials updated')
->success()
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
&& $record->status === 'disabled')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
@ -626,19 +572,25 @@ public static function table(Table $table): Table
->title('Provider connection enabled')
->success()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
&& $record->status !== 'disabled')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$previousStatus = (string) $record->status;
@ -673,7 +625,11 @@ public static function table(Table $table): Table
->title('Provider connection disabled')
->warning()
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')

View File

@ -10,18 +10,19 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
class EditProviderConnection extends EditRecord
{
@ -115,12 +116,12 @@ protected function getHeaderActions(): array
->visible(false),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Action::make('view_last_check_run')
->label('View last check run')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
&& OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
@ -145,8 +146,14 @@ protected function getHeaderActions(): array
}
return OperationRunLinks::view($run, $tenant);
}),
})
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->tooltip('You do not have permission to view provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
@ -160,36 +167,22 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
@ -247,14 +240,20 @@ protected function getHeaderActions(): array
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant))
->visible(fn (): bool => $tenant instanceof Tenant)
->form([
TextInput::make('client_id')
->label('Client ID')
@ -269,7 +268,9 @@ protected function getHeaderActions(): array
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
abort(404);
}
$credentials->upsertClientSecretCredential(
connection: $record,
@ -303,14 +304,19 @@ protected function getHeaderActions(): array
->title('Credentials updated')
->success()
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status !== 'disabled'
&& ! $record->is_default
&& ProviderConnection::query()
@ -320,7 +326,9 @@ protected function getHeaderActions(): array
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
abort(404);
}
$record->makeDefault();
@ -350,8 +358,14 @@ protected function getHeaderActions(): array
->title('Default connection updated')
->success()
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
@ -365,36 +379,22 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
@ -452,8 +452,14 @@ protected function getHeaderActions(): array
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
@ -467,36 +473,22 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->disabled(function (): bool {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
})
->tooltip(function (): ?string {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
? null
: 'You do not have permission to run provider operations.';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
@ -554,19 +546,25 @@ protected function getHeaderActions(): array
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status === 'disabled')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
@ -619,20 +617,26 @@ protected function getHeaderActions(): array
->title('Provider connection enabled')
->success()
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status !== 'disabled')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant) {
return;
}
$previousStatus = (string) $record->status;
@ -667,7 +671,12 @@ protected function getHeaderActions(): array
->title('Provider connection disabled')
->warning()
->send();
}),
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
@ -679,7 +688,17 @@ protected function getFormActions(): array
{
$tenant = Tenant::current();
if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) {
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return [
$this->getCancelFormAction(),
];
}
$capabilityResolver = app(CapabilityResolver::class);
if ($capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return parent::getFormActions();
}
@ -692,7 +711,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(404);
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
abort(404);
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
abort(403);
}
return parent::handleRecordUpdate($record, $data);
}

View File

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

View File

@ -14,6 +14,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SkipOrUuidRule;
use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
@ -27,6 +28,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
use BackedEnum;
@ -50,7 +52,6 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
@ -65,8 +66,18 @@ class RestoreRunResource extends Resource
public static function canCreate(): bool
{
return ($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant);
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
}
public static function form(Schema $schema): Schema
@ -748,6 +759,7 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
@ -761,18 +773,12 @@ public static function table(Table $table): Table
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$currentTenant = Tenant::current();
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
$tenant = $record->tenant;
$backupSet = $record->backupSet;
@ -933,19 +939,19 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->restore();
if ($record->tenant) {
@ -964,19 +970,19 @@ public static function table(Table $table): Table
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $record->isDeletable()) {
Notification::make()
->title('Restore run cannot be archived')
@ -1005,19 +1011,19 @@ public static function table(Table $table): Table
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
@ -1036,17 +1042,21 @@ public static function table(Table $table): Table
->success()
->send();
}),
fn () => Tenant::current(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->preserveVisibility()
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Restore Runs')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -1080,8 +1090,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -1122,14 +1130,16 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Restore Runs')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -1150,8 +1160,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -1203,14 +1211,16 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Restore Runs')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -1240,8 +1250,6 @@ public static function table(Table $table): Table
return;
}
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
@ -1293,6 +1301,9 @@ public static function table(Table $table): Table
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
]);
}
@ -1491,10 +1502,23 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
public static function createRestoreRun(array $data): RestoreRun
{
/** @var Tenant $tenant */
$tenant = Tenant::current();
$user = auth()->user();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);

View File

@ -5,12 +5,13 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord
@ -23,7 +24,21 @@ protected function authorizeAccess(): void
{
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(404);
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
abort(404);
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
}
public function getSteps(): array

View File

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

View File

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

View File

@ -7,14 +7,14 @@
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Auth\Capabilities;
use Filament\Actions;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
class TenantMembershipsRelationManager extends RelationManager
{
@ -40,18 +40,10 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
Actions\Action::make('add_member')
UiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
@ -80,10 +72,6 @@ public function table(Table $table): Table
abort(403);
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
@ -112,21 +100,18 @@ public function table(Table $table): Table
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
])
->actions([
Actions\Action::make('change_role')
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
@ -150,10 +135,6 @@ public function table(Table $table): Table
abort(403);
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
try {
$manager->changeRole(
tenant: $tenant,
@ -174,20 +155,18 @@ public function table(Table $table): Table
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
Actions\Action::make('remove')
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->apply(),
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
})
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
@ -200,10 +179,6 @@ public function table(Table $table): Table
abort(403);
}
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403);
}
try {
$manager->removeMember($tenant, $actor, $record);
} catch (\Throwable $throwable) {
@ -219,6 +194,12 @@ public function table(Table $table): Table
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage tenant memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}

View File

@ -5,9 +5,9 @@
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
class FindingPolicy
{
@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool
return false;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE);
}
}

View File

@ -19,6 +19,8 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
@ -40,6 +42,8 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
@ -58,6 +62,8 @@ class RoleCapabilityMap
TenantRole::Operator->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,

View File

@ -24,6 +24,12 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync';
// Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
// Findings
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
// Tenant memberships
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\Tenant;
use App\Models\User;
/**
* DTO representing the access context for a tenant-scoped UI action.
*
* Captures the current user, tenant, membership status, and capability check result
* for use by the UiEnforcement helper.
*/
final readonly class TenantAccessContext
{
public function __construct(
public ?User $user,
public ?Tenant $tenant,
public bool $isMember,
public bool $hasCapability,
) {}
/**
* Non-members should receive 404 (deny-as-not-found).
*/
public function shouldDenyAsNotFound(): bool
{
return ! $this->isMember;
}
/**
* Members without capability should receive 403 (forbidden).
*/
public function shouldDenyAsForbidden(): bool
{
return $this->isMember && ! $this->hasCapability;
}
/**
* User is authorized to perform the action.
*/
public function isAuthorized(): bool
{
return $this->isMember && $this->hasCapability;
}
}

View File

@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use ReflectionObject;
use Throwable;
/**
* Central RBAC UI Enforcement Helper for Filament Actions.
*
* Enforces constitution RBAC-UX rules:
* - Non-member hidden UI + 404 server-side
* - Member without capability visible-but-disabled + tooltip + 403 server-side
* - Member with capability enabled
* - Destructive actions requiresConfirmation()
*
* @see \App\Support\Rbac\UiTooltips
* @see \App\Support\Rbac\TenantAccessContext
*/
final class UiEnforcement
{
private Action|BulkAction $action;
private bool $requireMembership = true;
private ?string $capability = null;
private bool $isDestructive = false;
private ?string $customTooltip = null;
private Model|Closure|null $record = null;
private ?Collection $records = null;
private bool $isBulk = false;
private bool $preserveExistingVisibility = false;
private function __construct(Action|BulkAction $action)
{
$this->action = $action;
}
/**
* Create enforcement for a header/page action.
*
* @param Action $action The Filament action to wrap
*/
public static function forAction(Action $action): self
{
return new self($action);
}
/**
* Create enforcement for a table row action.
*
* @param Action $action The Filament action to wrap
* @param Model|Closure $record The record or a closure that returns the record
*/
public static function forTableAction(Action $action, Model|Closure $record): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
/**
* Create enforcement for a bulk action with all-or-nothing semantics.
*
* If any selected record fails the capability check for a member,
* the action is disabled entirely.
*
* @param BulkAction $action The Filament bulk action to wrap
*/
public static function forBulkAction(BulkAction $action): self
{
$instance = new self($action);
$instance->isBulk = true;
return $instance;
}
/**
* Require tenant membership for this action.
*
* @param bool $require Whether membership is required (default: true)
*/
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
return $this;
}
/**
* Require a specific capability for this action.
*
* @param string $capability A capability constant from Capabilities class
*
* @throws \InvalidArgumentException If capability is not in the canonical registry
*/
public function requireCapability(string $capability): self
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException(
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
);
}
$this->capability = $capability;
return $this;
}
/**
* Mark this action as destructive (requires confirmation modal).
*/
public function destructive(): self
{
$this->isDestructive = true;
return $this;
}
/**
* Override the default tooltip for disabled actions.
*
* @param string $message Custom tooltip message
*/
public function tooltip(string $message): self
{
$this->customTooltip = $message;
return $this;
}
/**
* Preserve the action's existing visibility logic.
*
* Use this when the action already has business-logic visibility
* (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept.
*
* UiEnforcement will combine the existing visibility condition with tenant
* membership visibility, instead of overwriting it.
*
* @return $this
*/
public function preserveVisibility(): self
{
$this->preserveExistingVisibility = true;
return $this;
}
/**
* Apply all enforcement rules to the action and return it.
*
* This sets up:
* - UI visibility (hidden for non-members)
* - UI disabled state + tooltip (for members without capability)
* - Destructive confirmation (if marked)
* - Server-side guards (404/403)
*
* @return Action|BulkAction The configured action
*/
public function apply(): Action|BulkAction
{
$this->applyVisibility();
$this->applyDisabledState();
$this->applyDestructiveConfirmation();
$this->applyServerSideGuard();
return $this->action;
}
/**
* Hide action for non-members.
*
* Skipped if preserveVisibility() was called.
*/
private function applyVisibility(): void
{
if (! $this->requireMembership) {
return;
}
$existingVisibility = $this->preserveExistingVisibility
? $this->getExistingVisibilityCondition()
: null;
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
return false;
}
if ($existingVisibility === null) {
return true;
}
return $this->evaluateVisibilityCondition($existingVisibility, $record);
});
}
/**
* Attempt to retrieve the existing visibility condition from the action.
*
* Filament stores this as the protected property `$isVisible` (bool|Closure)
* on actions via the CanBeHidden concern.
*/
private function getExistingVisibilityCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isVisible')) {
return null;
}
$property = $ref->getProperty('isVisible');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
/**
* Evaluate an existing bool|Closure visibility condition.
*
* This is a best-effort evaluator for business visibility closures.
* If the closure cannot be evaluated safely, we fail closed (return false).
*/
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return false;
}
return (bool) $condition($record);
} catch (Throwable) {
return false;
}
}
/**
* Disable action for members without capability.
*/
private function applyDisabledState(): void
{
if ($this->capability === null) {
return;
}
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
$this->action->disabled(function (?Model $record = null) {
$context = $this->resolveContextWithRecord($record);
// Non-members are hidden, so this only affects members
if (! $context->isMember) {
return true;
}
return ! $context->hasCapability;
});
// Only show tooltip when actually disabled
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
$context = $this->resolveContextWithRecord($record);
if ($context->isMember && ! $context->hasCapability) {
return $tooltip;
}
return null;
});
}
/**
* Add confirmation modal for destructive actions.
*/
private function applyDestructiveConfirmation(): void
{
if (! $this->isDestructive) {
return;
}
$this->action->requiresConfirmation();
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
}
/**
* Wrap the action handler with server-side authorization guard.
*
* This is a defense-in-depth measure. In normal operation, Filament's
* isDisabled() check prevents execution. This guard catches edge cases
* where the disabled check might be bypassed.
*/
private function applyServerSideGuard(): void
{
$this->action->before(function (?Model $record = null): void {
$context = $this->resolveContextWithRecord($record);
// Non-member → 404 (deny-as-not-found)
if ($context->shouldDenyAsNotFound()) {
abort(404);
}
// Member without capability → 403 (forbidden)
if ($context->shouldDenyAsForbidden()) {
abort(403);
}
});
}
/**
* Resolve the current access context with an optional record.
*/
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
{
$user = auth()->user();
// For table actions, resolve the record and use it as tenant if it's a Tenant
$tenant = $this->resolveTenantWithRecord($record);
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return new TenantAccessContext(
user: null,
tenant: null,
isMember: false,
hasCapability: false,
);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$isMember = $resolver->isMember($user, $tenant);
$hasCapability = true;
if ($this->capability !== null && $isMember) {
$hasCapability = $resolver->can($user, $tenant, $this->capability);
}
return new TenantAccessContext(
user: $user,
tenant: $tenant,
isMember: $isMember,
hasCapability: $hasCapability,
);
}
/**
* Resolve the tenant for this action with an optional record.
*
* Priority:
* 1. If $record is passed and is a Tenant, use it
* 2. If $this->record is set (for forTableAction), resolve it
* 3. Fall back to Filament::getTenant()
*/
private function resolveTenantWithRecord(?Model $record = null): ?Tenant
{
// If a record is passed directly (from closure parameter), check if it's a Tenant
if ($record instanceof Tenant) {
return $record;
}
// If a record is set from forTableAction, try to resolve it
if ($this->record !== null) {
$resolved = $this->record instanceof Closure
? ($this->record)()
: $this->record;
if ($resolved instanceof Tenant) {
return $resolved;
}
}
// Default: use Filament's current tenant
return Filament::getTenant();
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
/**
* Standardized tooltip and confirmation messages for RBAC UI enforcement.
*
* These constants provide consistent, non-leaky messaging for:
* - Permission denials (members lacking capability)
* - Destructive action confirmations
*
* @see \App\Support\Rbac\UiEnforcement
*/
final class UiTooltips
{
/**
* Tooltip shown when a member lacks the required capability.
* Intentionally vague to avoid leaking permission structure.
*/
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
/**
* Modal heading for destructive action confirmation.
*/
public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?';
/**
* Modal description for destructive action confirmation.
*/
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
}

View File

@ -18,17 +18,16 @@
'status' => 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));
});

View File

@ -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 Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -18,6 +19,7 @@
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

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

View File

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

View File

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

View File

@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,8 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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