066-rbac-ui-enforcement-helper #81

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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