Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m45s
Implemented the accepted risk resolution guidance, including the AcceptedRiskResolutionAdapter, guidance cards, and updated related Filament views. Added unit, feature, and browser tests.
742 lines
32 KiB
PHP
742 lines
32 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Filament\Resources;
|
||
|
||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
|
||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||
use App\Models\FindingException;
|
||
use App\Models\FindingExceptionEvidenceReference;
|
||
use App\Models\ManagedEnvironment;
|
||
use App\Models\User;
|
||
use App\Models\Workspace;
|
||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||
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\CanonicalNavigationContext;
|
||
use App\Support\Navigation\NavigationScope;
|
||
use App\Support\Navigation\RelatedContextEntry;
|
||
use App\Support\ResolutionGuidance\Adapters\AcceptedRiskResolutionAdapter;
|
||
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\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;
|
||
use WorkspaceScopedEnvironmentRoutes;
|
||
|
||
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
|
||
{
|
||
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
||
&& parent::shouldRegisterNavigation();
|
||
}
|
||
|
||
public static function canViewAny(): bool
|
||
{
|
||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||
$user = auth()->user();
|
||
|
||
if (! $tenant instanceof ManagedEnvironment || ! $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 ManagedEnvironment || ! $user instanceof User) {
|
||
return false;
|
||
}
|
||
|
||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
||
return false;
|
||
}
|
||
|
||
return ! $record instanceof FindingException
|
||
|| ((int) $record->managed_environment_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 ManagedEnvironment) {
|
||
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||
}
|
||
|
||
$counts = FindingException::query()
|
||
->where('managed_environment_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(__('localization.accepted_risk_guidance.section'))
|
||
->schema([
|
||
ViewEntry::make('accepted_risk_guidance')
|
||
->hiddenLabel()
|
||
->view('filament.infolists.entries.accepted-risk-guidance')
|
||
->state(fn (FindingException $record): array => static::acceptedRiskGuidance($record))
|
||
->columnSpanFull(),
|
||
])
|
||
->columnSpanFull(),
|
||
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('ManagedEnvironment'),
|
||
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 ManagedEnvironment) {
|
||
$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], tenant: $record->tenant),
|
||
targetKind: 'direct_record',
|
||
priority: 10,
|
||
actionLabel: 'Open finding',
|
||
contextBadge: 'Governance',
|
||
)->toArray();
|
||
}
|
||
|
||
if ($record->tenant instanceof ManagedEnvironment && 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 tenant’s governance workload.',
|
||
targetUrl: static::approvalQueueUrl($record->tenant, $record, static::navigationContext()),
|
||
targetKind: 'canonical_page',
|
||
priority: 20,
|
||
actionLabel: 'Open approval queue',
|
||
contextBadge: 'Queue',
|
||
)->toArray();
|
||
}
|
||
|
||
return $entries;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
public static function acceptedRiskGuidance(FindingException $record): array
|
||
{
|
||
$findingUrl = null;
|
||
|
||
if ($record->finding && $record->tenant instanceof ManagedEnvironment) {
|
||
$findingUrl = FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant);
|
||
}
|
||
|
||
$queueUrl = $record->tenant instanceof ManagedEnvironment && static::canAccessApprovalQueueForTenant($record->tenant)
|
||
? static::approvalQueueUrl($record->tenant, $record, static::navigationContext())
|
||
: null;
|
||
|
||
/** @var AcceptedRiskResolutionAdapter $adapter */
|
||
$adapter = app(AcceptedRiskResolutionAdapter::class);
|
||
|
||
return $adapter->forDetail(
|
||
$record,
|
||
queueUrl: $queueUrl,
|
||
findingUrl: $findingUrl,
|
||
);
|
||
}
|
||
|
||
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 ManagedEnvironment) {
|
||
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 ManagedEnvironment) {
|
||
return [];
|
||
}
|
||
|
||
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||
|
||
return User::query()
|
||
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||
->orderBy('name')
|
||
->orderBy('email')
|
||
->get(['id', 'name', 'email'])
|
||
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||
->mapWithKeys(fn (User $user): array => [
|
||
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||
])
|
||
->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 ManagedEnvironment
|
||
&& $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(?ManagedEnvironment $tenant = null): bool
|
||
{
|
||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||
$user = auth()->user();
|
||
|
||
if (! $tenant instanceof ManagedEnvironment || ! $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(
|
||
?ManagedEnvironment $tenant = null,
|
||
?FindingException $exception = null,
|
||
?CanonicalNavigationContext $navigationContext = null,
|
||
): ?string
|
||
{
|
||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||
|
||
if (! $tenant instanceof ManagedEnvironment) {
|
||
return null;
|
||
}
|
||
|
||
$parameters = array_merge(
|
||
$navigationContext?->toQuery() ?? [],
|
||
array_filter([
|
||
'locale' => request()->query('locale'),
|
||
'environment_id' => (int) $tenant->getKey(),
|
||
'exception' => $exception?->getKey(),
|
||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||
);
|
||
|
||
return FindingExceptionsQueue::getUrl(
|
||
panel: 'admin',
|
||
parameters: $parameters,
|
||
);
|
||
}
|
||
|
||
private static function navigationContext(): ?CanonicalNavigationContext
|
||
{
|
||
return CanonicalNavigationContext::fromRequest(request());
|
||
}
|
||
}
|