feat: bridge finding exception approval queue

This commit is contained in:
Ahmed Darrazi 2026-03-28 11:07:19 +01:00
parent f73d3623fc
commit 419a289dd8
8 changed files with 143 additions and 30 deletions

View File

@ -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,
]);
}
}

View File

@ -64,6 +64,11 @@ class FindingResource extends Resource
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
/**
* @var array<string, RelatedContextEntry|null>
*/
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')

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class OpenFindingExceptionsQueueController extends Controller
{
public function __invoke(Request $request, Tenant $tenant): RedirectResponse
{
$user = auth()->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'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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