558 lines
17 KiB
PHP
558 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\OperationRun;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class BaselineCompareEvidenceGapDetails
|
|
{
|
|
/**
|
|
* @return array{
|
|
* summary: array{
|
|
* count: int,
|
|
* by_reason: array<string, int>,
|
|
* detail_state: string,
|
|
* recorded_subjects_total: int,
|
|
* missing_detail_count: int
|
|
* },
|
|
* buckets: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* count: int,
|
|
* recorded_count: int,
|
|
* missing_detail_count: int,
|
|
* detail_state: string,
|
|
* search_text: string,
|
|
* rows: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
* }>
|
|
* }
|
|
*/
|
|
public static function fromOperationRun(?OperationRun $run): array
|
|
{
|
|
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
|
return self::empty();
|
|
}
|
|
|
|
return self::fromContext($run->context);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{
|
|
* summary: array{
|
|
* count: int,
|
|
* by_reason: array<string, int>,
|
|
* detail_state: string,
|
|
* recorded_subjects_total: int,
|
|
* missing_detail_count: int
|
|
* },
|
|
* buckets: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* count: int,
|
|
* recorded_count: int,
|
|
* missing_detail_count: int,
|
|
* detail_state: string,
|
|
* search_text: string,
|
|
* rows: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
* }>
|
|
* }
|
|
*/
|
|
public static function fromContext(array $context): array
|
|
{
|
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
|
|
|
if (! is_array($baselineCompare)) {
|
|
return self::empty();
|
|
}
|
|
|
|
return self::fromBaselineCompare($baselineCompare);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineCompare
|
|
* @return array{
|
|
* summary: array{
|
|
* count: int,
|
|
* by_reason: array<string, int>,
|
|
* detail_state: string,
|
|
* recorded_subjects_total: int,
|
|
* missing_detail_count: int
|
|
* },
|
|
* buckets: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* count: int,
|
|
* recorded_count: int,
|
|
* missing_detail_count: int,
|
|
* detail_state: string,
|
|
* search_text: string,
|
|
* rows: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
* }>
|
|
* }
|
|
*/
|
|
public static function fromBaselineCompare(array $baselineCompare): array
|
|
{
|
|
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
|
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
|
|
|
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
|
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
|
|
|
foreach ($subjects as $reason => $keys) {
|
|
if (! array_key_exists($reason, $byReason)) {
|
|
$byReason[$reason] = count($keys);
|
|
}
|
|
}
|
|
|
|
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
|
|
$detailState = self::detailState($count, $subjects);
|
|
|
|
$buckets = [];
|
|
|
|
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
|
|
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
|
|
$reasonCount = $byReason[$reason] ?? count($rows);
|
|
|
|
if ($reasonCount <= 0 && $rows === []) {
|
|
continue;
|
|
}
|
|
|
|
$recordedCount = count($rows);
|
|
$searchText = trim(implode(' ', array_filter([
|
|
Str::lower($reason),
|
|
Str::lower(self::reasonLabel($reason)),
|
|
...array_map(
|
|
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
|
$rows,
|
|
),
|
|
])));
|
|
|
|
$buckets[] = [
|
|
'reason_code' => $reason,
|
|
'reason_label' => self::reasonLabel($reason),
|
|
'count' => $reasonCount,
|
|
'recorded_count' => $recordedCount,
|
|
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
|
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
|
|
'search_text' => $searchText,
|
|
'rows' => $rows,
|
|
];
|
|
}
|
|
|
|
$recordedSubjectsTotal = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
|
|
return [
|
|
'summary' => [
|
|
'count' => $count,
|
|
'by_reason' => $byReason,
|
|
'detail_state' => $detailState,
|
|
'recorded_subjects_total' => $recordedSubjectsTotal,
|
|
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
|
],
|
|
'buckets' => $buckets,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baselineCompare
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function diagnosticsPayload(array $baselineCompare): array
|
|
{
|
|
return array_filter([
|
|
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
|
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
|
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
|
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
|
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
|
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
|
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
public static function reasonLabel(string $reason): string
|
|
{
|
|
$reason = trim($reason);
|
|
|
|
return match ($reason) {
|
|
'ambiguous_match' => 'Ambiguous inventory match',
|
|
'policy_not_found' => 'Policy not found',
|
|
'missing_current' => 'Missing current evidence',
|
|
'invalid_subject' => 'Invalid subject',
|
|
'duplicate_subject' => 'Duplicate subject',
|
|
'capture_failed' => 'Evidence capture failed',
|
|
'budget_exhausted' => 'Capture budget exhausted',
|
|
'throttled' => 'Graph throttled',
|
|
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
|
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
|
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
|
'rollout_disabled' => 'Rollout disabled',
|
|
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
|
*/
|
|
public static function topReasons(array $byReason, int $limit = 5): array
|
|
{
|
|
$normalized = self::normalizeCounts($byReason);
|
|
arsort($normalized);
|
|
|
|
return array_map(
|
|
static fn (string $reason, int $count): array => [
|
|
'reason_code' => $reason,
|
|
'reason_label' => self::reasonLabel($reason),
|
|
'count' => $count,
|
|
],
|
|
array_slice(array_keys($normalized), 0, $limit),
|
|
array_slice(array_values($normalized), 0, $limit),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $buckets
|
|
* @return list<array{
|
|
* __id: string,
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
*/
|
|
public static function tableRows(array $buckets): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach ($buckets as $bucket) {
|
|
if (! is_array($bucket)) {
|
|
continue;
|
|
}
|
|
|
|
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
|
|
|
foreach ($bucketRows as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
|
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
|
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
|
|
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
|
|
'reason_code' => $reasonCode,
|
|
'reason_label' => $reasonLabel,
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'search_text' => Str::lower(implode(' ', [
|
|
$reasonCode,
|
|
$reasonLabel,
|
|
$policyType,
|
|
$subjectKey,
|
|
])),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function reasonFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['reason_code'] => (string) $row['reason_label'],
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function policyTypeFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->pluck('policy_type')
|
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->mapWithKeys(fn (string $value): array => [$value => $value])
|
|
->sortKeysUsing('strnatcasecmp')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* summary: array{
|
|
* count: int,
|
|
* by_reason: array<string, int>,
|
|
* detail_state: string,
|
|
* recorded_subjects_total: int,
|
|
* missing_detail_count: int
|
|
* },
|
|
* buckets: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* count: int,
|
|
* recorded_count: int,
|
|
* missing_detail_count: int,
|
|
* detail_state: string,
|
|
* search_text: string,
|
|
* rows: list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
* }>
|
|
* }
|
|
*/
|
|
private static function empty(): array
|
|
{
|
|
return [
|
|
'summary' => [
|
|
'count' => 0,
|
|
'by_reason' => [],
|
|
'detail_state' => 'no_gaps',
|
|
'recorded_subjects_total' => 0,
|
|
'missing_detail_count' => 0,
|
|
],
|
|
'buckets' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private static function normalizeCounts(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($value as $reason => $count) {
|
|
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
|
continue;
|
|
}
|
|
|
|
$intCount = (int) $count;
|
|
|
|
if ($intCount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[trim($reason)] = $intCount;
|
|
}
|
|
|
|
arsort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, list<string>>
|
|
*/
|
|
private static function normalizeSubjects(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($value as $reason => $keys) {
|
|
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
|
|
continue;
|
|
}
|
|
|
|
$items = array_values(array_unique(array_filter(array_map(
|
|
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
|
|
$keys,
|
|
))));
|
|
|
|
if ($items === []) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[trim($reason)] = $items;
|
|
}
|
|
|
|
ksort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @param array<string, list<string>> $subjects
|
|
* @return list<string>
|
|
*/
|
|
private static function orderedReasons(array $byReason, array $subjects): array
|
|
{
|
|
$reasons = array_keys($byReason);
|
|
|
|
foreach (array_keys($subjects) as $reason) {
|
|
if (! in_array($reason, $reasons, true)) {
|
|
$reasons[] = $reason;
|
|
}
|
|
}
|
|
|
|
return $reasons;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @param array<string, list<string>> $subjects
|
|
*/
|
|
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
|
{
|
|
if (is_numeric($count)) {
|
|
$intCount = (int) $count;
|
|
|
|
if ($intCount >= 0) {
|
|
return $intCount;
|
|
}
|
|
}
|
|
|
|
$byReasonCount = array_sum($byReason);
|
|
|
|
if ($byReasonCount > 0) {
|
|
return $byReasonCount;
|
|
}
|
|
|
|
return array_sum(array_map(
|
|
static fn (array $keys): int => count($keys),
|
|
$subjects,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, list<string>> $subjects
|
|
*/
|
|
private static function detailState(int $count, array $subjects): string
|
|
{
|
|
if ($count <= 0) {
|
|
return 'no_gaps';
|
|
}
|
|
|
|
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $subjects
|
|
* @return list<array{
|
|
* reason_code: string,
|
|
* reason_label: string,
|
|
* policy_type: string,
|
|
* subject_key: string,
|
|
* search_text: string
|
|
* }>
|
|
*/
|
|
private static function rowsForReason(string $reason, array $subjects): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach ($subjects as $subject) {
|
|
[$policyType, $subjectKey] = self::splitSubject($subject);
|
|
|
|
if ($policyType === null || $subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'reason_code' => $reason,
|
|
'reason_label' => self::reasonLabel($reason),
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'search_text' => Str::lower(implode(' ', [
|
|
$reason,
|
|
self::reasonLabel($reason),
|
|
$policyType,
|
|
$subjectKey,
|
|
])),
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: ?string, 1: ?string}
|
|
*/
|
|
private static function splitSubject(string $subject): array
|
|
{
|
|
$parts = explode('|', $subject, 2);
|
|
|
|
if (count($parts) !== 2) {
|
|
return [null, null];
|
|
}
|
|
|
|
$policyType = trim($parts[0]);
|
|
$subjectKey = trim($parts[1]);
|
|
|
|
if ($policyType === '' || $subjectKey === '') {
|
|
return [null, null];
|
|
}
|
|
|
|
return [$policyType, $subjectKey];
|
|
}
|
|
|
|
private static function stringOrNull(mixed $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
private static function intOrNull(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
}
|