feat: harden finding governance health surfaces #197
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user