## Summary - add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support - add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament - add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity ## Validation - vendor/bin/sail bin pint --dirty --format agent - CI=1 vendor/bin/sail artisan test --compact - manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility ## Notes - Filament implementation remains on v5 with Livewire v4-compatible surfaces - canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php - finding exceptions stay out of global search in this rollout Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #184
489 lines
22 KiB
PHP
489 lines
22 KiB
PHP
<?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\Models\FindingException;
|
|
use App\Models\FindingExceptionEvidenceReference;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
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\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\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 linked finding navigation plus state-aware renewal and revocation actions.');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return static::getTenantOwnedEloquentQuery()
|
|
->with(static::relationshipsForView());
|
|
}
|
|
|
|
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('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()),
|
|
]);
|
|
}
|
|
|
|
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_summary')
|
|
->label('Finding')
|
|
->state(fn (FindingException $record): string => static::findingSummary($record))
|
|
->searchable(),
|
|
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(),
|
|
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()),
|
|
])
|
|
->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
|
|
{
|
|
$summary = $record->finding?->resolvedSubjectDisplayName();
|
|
|
|
if (is_string($summary) && trim($summary) !== '') {
|
|
return trim($summary);
|
|
}
|
|
|
|
return 'Finding #'.$record->finding_id;
|
|
}
|
|
|
|
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 ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
|
return 'warning';
|
|
}
|
|
|
|
return 'danger';
|
|
}
|
|
}
|