## Summary - introduce the governance subject taxonomy registry and canonical Baseline Scope V2 normalization and persistence - update baseline profile Filament surfaces, validation, capture/compare gating, and add the optional scope backfill command with audit logging - add focused unit, feature, Filament, and browser smoke coverage for save-forward behavior, operation truth, authorization continuity, and invalid-scope rendering - remove the duplicate legacy spec plan under `specs/001-governance-subject-taxonomy/plan.md` ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec202GovernanceSubjectTaxonomySmokeTest.php` - focused Spec 202 regression pack: `56 passed (300 assertions)` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - no schema migration required - no new Filament asset registration required - branch includes the final browser smoke test coverage for the current feature Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #232
185 lines
6.8 KiB
PHP
185 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\BaselineProfileResource;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
describe('BaselineProfile RBAC — 404 vs 403 semantics', function () {
|
|
it('denies non-members accessing the list page', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$response = $this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl(panel: 'admin'));
|
|
|
|
expect($response->status())->toBeIn([403, 404, 302], 'Non-members should not get HTTP 200');
|
|
});
|
|
|
|
it('returns 404 for members accessing a profile from another workspace', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
$profile = BaselineProfile::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 200 for readonly members accessing list page', function (): void {
|
|
[$user] = createUserWithTenant(role: 'readonly');
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl(panel: 'admin'))
|
|
->assertOk();
|
|
});
|
|
|
|
it('returns 403 for members with mocked missing capability on list page', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'readonly',
|
|
]);
|
|
|
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
$resolver->shouldReceive('can')->andReturnFalse();
|
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl(panel: 'admin'))
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('returns 403 for readonly members accessing create page', function (): void {
|
|
[$user] = createUserWithTenant(role: 'readonly');
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('create', panel: 'admin'))
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('returns 200 for owner members accessing create page', function (): void {
|
|
[$user] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('create', panel: 'admin'))
|
|
->assertOk();
|
|
});
|
|
|
|
it('returns 404 for members accessing profile from another workspace', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
$profile = BaselineProfile::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 403 for readonly members accessing edit page', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
|
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
|
$profile = BaselineProfile::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('returns 200 for owner members accessing edit page', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
|
$profile = BaselineProfile::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
|
->assertOk();
|
|
});
|
|
|
|
it('keeps edit-page authorization stable for legacy-scope profiles', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
|
|
$profile = BaselineProfile::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]);
|
|
|
|
DB::table('baseline_profiles')
|
|
->where('id', (int) $profile->getKey())
|
|
->update([
|
|
'scope_jsonb' => json_encode([
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'foundation_types' => [],
|
|
], JSON_THROW_ON_ERROR),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$this->actingAs($owner)
|
|
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
|
->assertOk();
|
|
|
|
$this->actingAs($readonly)
|
|
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
|
->assertForbidden();
|
|
});
|
|
});
|
|
|
|
describe('BaselineProfile static authorization methods', function () {
|
|
it('canViewAny returns false for non-members', function (): void {
|
|
$user = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
expect(BaselineProfileResource::canViewAny())->toBeFalse();
|
|
});
|
|
|
|
it('canViewAny returns true for members', function (): void {
|
|
[$user] = createUserWithTenant(role: 'readonly');
|
|
|
|
$this->actingAs($user);
|
|
|
|
expect(BaselineProfileResource::canViewAny())->toBeTrue();
|
|
});
|
|
|
|
it('canCreate returns true for managers and false for readonly', function (): void {
|
|
[$owner] = createUserWithTenant(role: 'owner');
|
|
$this->actingAs($owner);
|
|
expect(BaselineProfileResource::canCreate())->toBeTrue();
|
|
|
|
[$readonly] = createUserWithTenant(role: 'readonly');
|
|
$this->actingAs($readonly);
|
|
expect(BaselineProfileResource::canCreate())->toBeFalse();
|
|
});
|
|
});
|