diff --git a/app/Filament/Resources/FindingExceptionResource.php b/app/Filament/Resources/FindingExceptionResource.php index 25fd412e..dd1e1bef 100644 --- a/app/Filament/Resources/FindingExceptionResource.php +++ b/app/Filament/Resources/FindingExceptionResource.php @@ -6,7 +6,6 @@ use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; -use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Resources\FindingExceptionResource\Pages; use App\Models\FindingException; use App\Models\FindingExceptionEvidenceReference; @@ -624,8 +623,8 @@ public static function approvalQueueUrl(?Tenant $tenant = null): ?string return null; } - return FindingExceptionsQueue::getUrl([ - 'tenant' => (string) $tenant->getKey(), - ], panel: 'admin'); + return route('admin.finding-exceptions.open-queue', [ + 'tenant' => (string) $tenant->external_id, + ]); } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 77fae062..51c9e21f 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -64,6 +64,11 @@ class FindingResource extends Resource use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; + /** + * @var array + */ + private static array $primaryRelatedEntryCache = []; + protected static ?string $model = Finding::class; protected static ?string $tenantOwnershipRelationshipName = 'tenant'; @@ -879,9 +884,7 @@ public static function table(Table $table): Table }), FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) - ->recordUrl(static fn (Finding $record): ?string => static::canView($record) - ? static::getUrl('view', ['record' => $record]) - : null) + ->recordUrl(static fn (Finding $record): string => static::getUrl('view', ['record' => $record])) ->actions([ static::primaryRelatedAction(), Actions\ActionGroup::make([ @@ -1250,7 +1253,15 @@ private static function primaryRelatedAction(): Actions\Action private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry { - return app(RelatedNavigationResolver::class) + $cacheKey = is_numeric($record->getKey()) + ? (string) $record->getKey() + : spl_object_hash($record); + + if (array_key_exists($cacheKey, static::$primaryRelatedEntryCache)) { + return static::$primaryRelatedEntryCache[$cacheKey]; + } + + return static::$primaryRelatedEntryCache[$cacheKey] = app(RelatedNavigationResolver::class) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record); } @@ -1305,7 +1316,7 @@ public static function triageAction(): Actions\Action ->label('Triage') ->icon('heroicon-o-check') ->color('gray') - ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [ + ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, Finding::STATUS_ACKNOWLEDGED, @@ -1331,7 +1342,7 @@ public static function startProgressAction(): Actions\Action ->label('Start progress') ->icon('heroicon-o-play') ->color('info') - ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [ + ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED, ], true)) @@ -1356,7 +1367,7 @@ public static function assignAction(): Actions\Action ->label('Assign') ->icon('heroicon-o-user-plus') ->color('gray') - ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->fillForm(fn (Finding $record): array => [ 'assignee_user_id' => $record->assignee_user_id, 'owner_user_id' => $record->owner_user_id, @@ -1400,7 +1411,7 @@ public static function resolveAction(): Actions\Action ->label('Resolve') ->icon('heroicon-o-check-badge') ->color('success') - ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->requiresConfirmation() ->form([ Textarea::make('resolved_reason') @@ -1435,7 +1446,7 @@ public static function closeAction(): Actions\Action ->label('Close') ->icon('heroicon-o-x-circle') ->color('danger') - ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->requiresConfirmation() ->form([ Textarea::make('closed_reason') @@ -1470,7 +1481,7 @@ public static function requestExceptionAction(): Actions\Action ->label('Request exception') ->icon('heroicon-o-shield-exclamation') ->color('warning') - ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->requiresConfirmation() ->form([ Select::make('owner_user_id') @@ -1531,9 +1542,9 @@ public static function renewExceptionAction(): Actions\Action ->label('Renew exception') ->icon('heroicon-o-arrow-path') ->color('warning') - ->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false) + ->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false) ->fillForm(fn (Finding $record): array => [ - 'owner_user_id' => static::currentFindingException($record)?->owner_user_id, + 'owner_user_id' => static::loadedFindingException($record)?->owner_user_id, ]) ->requiresConfirmation() ->form([ @@ -1595,7 +1606,7 @@ public static function revokeExceptionAction(): Actions\Action ->label('Revoke exception') ->icon('heroicon-o-no-symbol') ->color('danger') - ->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false) + ->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false) ->requiresConfirmation() ->form([ Textarea::make('revocation_reason') @@ -1622,7 +1633,7 @@ public static function reopenAction(): Actions\Action ->icon('heroicon-o-arrow-uturn-left') ->color('warning') ->requiresConfirmation() - ->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record))) + ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, @@ -1820,6 +1831,21 @@ private static function currentFindingException(Finding $record): ?FindingExcept return static::resolvedFindingException($finding); } + private static function loadedFindingException(Finding $finding): ?FindingException + { + $exception = $finding->relationLoaded('findingException') + ? $finding->findingException + : null; + + if (! $exception instanceof FindingException) { + return null; + } + + $exception->loadMissing('currentDecision'); + + return $exception; + } + private static function resolvedFindingException(Finding $finding): ?FindingException { $exception = $finding->relationLoaded('findingException') diff --git a/app/Http/Controllers/OpenFindingExceptionsQueueController.php b/app/Http/Controllers/OpenFindingExceptionsQueueController.php new file mode 100644 index 00000000..ffc66127 --- /dev/null +++ b/app/Http/Controllers/OpenFindingExceptionsQueueController.php @@ -0,0 +1,60 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $workspaceContext = app(WorkspaceContext::class); + + if (! $workspaceContext->isMember($user, $workspace)) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) { + abort(404); + } + + $workspaceContext->setCurrentWorkspace($workspace, $user, $request); + + if (! $workspaceContext->rememberTenantContext($tenant, $request)) { + abort(404); + } + + return redirect()->to(FindingExceptionsQueue::getUrl([ + 'tenant' => (string) $tenant->external_id, + ], panel: 'admin')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 10f63218..ec5d145f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Auth\CapabilityResolver; use App\Services\Tenants\TenantOperabilityService; use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; @@ -119,15 +120,10 @@ public function tenantRoleValue(Tenant $tenant): ?string return null; } - $role = $this->tenants() - ->whereKey($tenant->getKey()) - ->value('role'); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); - if (! is_string($role)) { - return null; - } - - return $role; + return $resolver->getRole($this, $tenant)?->value; } public function allowsTenantSync(Tenant $tenant): bool @@ -145,9 +141,10 @@ public function canAccessTenant(Model $tenant): bool return false; } - return $this->tenantMemberships() - ->where('tenant_id', $tenant->getKey()) - ->exists(); + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($this, $tenant); } public function getTenants(Panel $panel): array|Collection diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8dcc6432..fcfa7d06 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,8 @@ use App\Policies\EntraGroupPolicy; use App\Policies\FindingPolicy; use App\Policies\OperationRunPolicy; +use App\Services\Auth\CapabilityResolver; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer; use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer; use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer; @@ -76,6 +78,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->singleton(CapabilityResolver::class); + $this->app->singleton(WorkspaceCapabilityResolver::class); + $this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class); $this->app->bind( diff --git a/routes/web.php b/routes/web.php index 8e5af82b..a9ceb8da 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\ClearTenantContextController; +use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\ReviewPackDownloadController; use App\Http\Controllers\SelectTenantController; @@ -67,6 +68,10 @@ ->post('/admin/switch-workspace', SwitchWorkspaceController::class) ->name('admin.switch-workspace'); +Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) + ->get('/admin/finding-exceptions/open-queue/{tenant}', OpenFindingExceptionsQueueController::class) + ->name('admin.finding-exceptions.open-queue'); + Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected']) ->post('/admin/select-tenant', SelectTenantController::class) ->name('admin.select-tenant'); diff --git a/specs/166-finding-governance-health/tasks.md b/specs/166-finding-governance-health/tasks.md index 55590a21..a011ce66 100644 --- a/specs/166-finding-governance-health/tasks.md +++ b/specs/166-finding-governance-health/tasks.md @@ -41,7 +41,7 @@ ## Phase 3: User Story 1 - Distinguish Safe Accepted Risk From Governance Drift ### Tests for User Story 1 - [X] T007 [P] [US1] Add findings-list coverage for healthy versus expiring, expired, revoked, rejected where operator-visible, or missing-support accepted risk, overdue prioritization, owner or assignee promotion, and governance-aware filters in `tests/Feature/Findings/FindingsListDefaultsTest.php` and `tests/Feature/Findings/FindingsListFiltersTest.php` -- [ ] T008 [P] [US1] Add tenant-register and canonical-queue parity coverage for governance-validity visibility across expiring, expired, revoked, rejected where operator-visible, and missing-support states, plus tenant-prefilter handoff and authorized tenant-filter broadening in `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, and `tests/Feature/Findings/FindingRelatedNavigationTest.php` +- [X] T008 [P] [US1] Add tenant-register and canonical-queue parity coverage for governance-validity visibility across expiring, expired, revoked, rejected where operator-visible, and missing-support states, plus tenant-prefilter handoff and authorized tenant-filter broadening in `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, and `tests/Feature/Findings/FindingRelatedNavigationTest.php` - [ ] T009 [P] [US1] Add positive and negative authorization coverage for tenant findings and canonical exception governance states, including `404` versus `403` outcomes and no-regression capability gating for existing mutation actions in `tests/Feature/Findings/FindingRbacTest.php` and `tests/Feature/Findings/FindingExceptionAuthorizationTest.php` ### Implementation for User Story 1 diff --git a/tests/Feature/Findings/FindingExceptionRegisterTest.php b/tests/Feature/Findings/FindingExceptionRegisterTest.php index 994809b6..001fa772 100644 --- a/tests/Feature/Findings/FindingExceptionRegisterTest.php +++ b/tests/Feature/Findings/FindingExceptionRegisterTest.php @@ -9,7 +9,9 @@ use App\Models\Finding; use App\Models\FindingException; use App\Models\User; +use App\Models\Workspace; use App\Services\Findings\FindingRiskGovernanceResolver; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -127,6 +129,25 @@ ->assertTableEmptyStateActionsExistInOrder(['open_findings']); }); +it('bridges tenant approval queue links into the admin workspace context', function (): void { + [$viewer, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + + $this->actingAs($viewer) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $otherWorkspace->getKey()]) + ->get(route('admin.finding-exceptions.open-queue', ['tenant' => (string) $tenant->external_id])) + ->assertRedirect( + \App\Filament\Pages\Monitoring\FindingExceptionsQueue::getUrl([ + 'tenant' => (string) $tenant->external_id, + ], panel: 'admin') + ); + + expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id) + ->and(session(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [])) + ->toHaveKey((string) $tenant->workspace_id, (int) $tenant->getKey()); +}); + // --- Enterprise UX Hardening (Spec 166 Phase 6b) --- it('shows finding severity badge in exception register table', function (): void {