TenantAtlas/apps/platform/app/Filament/Resources/FindingExceptionResource.php
ahmido 9f6985291e feat: implement spec 192 record page header discipline (#226)
## Summary
- implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage
- add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline`
- improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL
- disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation

## Notes
- Livewire v4 / Filament v5 compliance remains unchanged.
- Provider registration stays in `bootstrap/providers.php`.
- No Global Search behavior was expanded.
- No destructive action confirmation semantics were relaxed.
- The full test suite was not run in this PR.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #226
2026-04-11 21:20:41 +00:00

681 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use UnitEnum;
class FindingExceptionResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = FindingException::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Risk exceptions';
protected static ?int $navigationSort = 60;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
return false;
}
return ! $record instanceof FindingException
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with(static::relationshipsForView());
}
/**
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
*/
public static function exceptionStatsForCurrentTenant(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
}
$counts = FindingException::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->selectRaw('count(*) as total')
->selectRaw("count(*) filter (where status = 'active') as active")
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
->selectRaw("count(*) filter (where status = 'expired') as expired")
->selectRaw("count(*) filter (where status = 'pending') as pending")
->first();
return [
'active' => (int) ($counts?->active ?? 0),
'expiring' => (int) ($counts?->expiring ?? 0),
'expired' => (int) ($counts?->expired ?? 0),
'pending' => (int) ($counts?->pending ?? 0),
'total' => (int) ($counts?->total ?? 0),
];
}
public static function resolveScopedRecordOrFail(int|string|null $record): Model
{
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Exception')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextEntry::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextEntry::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->columnSpanFull()
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record)),
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
])
->columns(2),
Section::make('Decision history')
->schema([
RepeatableEntry::make('decisions')
->hiddenLabel()
->schema([
TextEntry::make('decision_type')->label('Decision'),
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
])
->columns(3),
]),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Evidence references')
->schema([
RepeatableEntry::make('evidenceReferences')
->hiddenLabel()
->schema([
TextEntry::make('label')->label('Label'),
TextEntry::make('source_type')->label('Source'),
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
TextEntry::make('summary_payload')
->label('Summary')
->state(function (FindingExceptionEvidenceReference $record): ?string {
if ($record->summary_payload === [] || $record->summary_payload === null) {
return null;
}
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
})
->placeholder('—')
->columnSpanFull(),
])
->columns(2),
])
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public static function relatedContextEntries(FindingException $record): array
{
$entries = [];
if ($record->finding && $record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'finding',
label: 'Finding',
value: static::findingSummary($record),
secondaryValue: 'Return to the linked finding detail.',
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open finding',
contextBadge: 'Governance',
)->toArray();
}
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
$entries[] = RelatedContextEntry::available(
key: 'approval_queue',
label: 'Approval queue',
value: 'Review pending exception requests',
secondaryValue: 'Return to the queue for the rest of this tenants governance workload.',
targetUrl: static::approvalQueueUrl($record->tenant),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open approval queue',
contextBadge: 'Queue',
)->toArray();
}
return $entries;
}
public static function table(Table $table): Table
{
return $table
->defaultSort('requested_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->sortable(),
Tables\Columns\TextColumn::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->sortable(),
Tables\Columns\TextColumn::make('finding.severity')
->label('Severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
->placeholder('—')
->sortable(),
Tables\Columns\TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record))
->searchable()
->wrap()
->limit(60),
Tables\Columns\TextColumn::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->wrap(),
Tables\Columns\TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
Tables\Columns\TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
Tables\Columns\TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable()
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
Tables\Columns\TextColumn::make('expires_at')
->label('Expires')
->dateTime()
->placeholder('—')
->sortable()
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
Tables\Columns\TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
SelectFilter::make('finding_severity')
->label('Finding severity')
->options(FilterOptionCatalog::findingSeverities())
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
: $query),
])
->actions([
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
abort(404);
}
try {
$service->renew($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->send();
}),
Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
try {
$service->revoke($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->send();
}),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this view')
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
->emptyStateIcon('heroicon-o-shield-exclamation')
->emptyStateActions([
Action::make('open_findings')
->label('Open findings')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (): string => FindingResource::getUrl('index')),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListFindingExceptions::route('/'),
'view' => Pages\ViewFindingException::route('/{record}'),
];
}
/**
* @return array<int, string|array<int|string, mixed>>
*/
private static function relationshipsForView(): array
{
return [
'tenant',
'requester',
'owner',
'approver',
'currentDecision',
'decisions.actor',
'evidenceReferences',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
];
}
/**
* @return array<int, string>
*/
private static function tenantMemberOptions(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return [];
}
return \App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
->orderBy('users.name')
->pluck('users.name', 'users.id')
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
->all();
}
private static function findingSummary(FindingException $record): string
{
$finding = $record->finding;
if (! $finding instanceof \App\Models\Finding) {
return 'Finding #'.$record->finding_id;
}
$displayName = $finding->resolvedSubjectDisplayName();
$findingType = $finding->finding_type;
$parts = [];
if (is_string($displayName) && trim($displayName) !== '') {
$parts[] = trim($displayName);
}
if (is_string($findingType) && trim($findingType) !== '') {
$label = str_replace('_', ' ', trim($findingType));
$parts[] = '('.ucfirst($label).')';
}
if ($parts !== []) {
return implode(' ', $parts);
}
return 'Finding #'.$record->finding_id;
}
public static function relativeTimeDescription(mixed $date): ?string
{
if (! $date instanceof \DateTimeInterface) {
return null;
}
$carbon = \Illuminate\Support\Carbon::instance($date);
if ($carbon->isToday()) {
return 'Today';
}
if ($carbon->isPast()) {
return $carbon->diffForHumans();
}
if ($carbon->isTomorrow()) {
return 'Tomorrow';
}
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
if ($daysUntil <= 14) {
return 'In '.$daysUntil.' days';
}
return $carbon->diffForHumans();
}
private static function canManageRecord(FindingException $record): bool
{
$user = auth()->user();
return $user instanceof User
&& $record->tenant instanceof Tenant
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
private static function governanceWarning(FindingException $record): ?string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if (! $finding instanceof \App\Models\Finding) {
return null;
}
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
}
private static function governanceWarningColor(FindingException $record): string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
return 'warning';
}
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
{
$tenant ??= static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
{
$tenant ??= static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return route('admin.finding-exceptions.open-queue', [
'tenant' => (string) $tenant->external_id,
]);
}
}