Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m12s
Replaced legacy tenant and environment bindings in the BaselineDriftEngine with the new ProviderResourceIdentity framework as defined in Spec 382.
441 lines
17 KiB
PHP
441 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\PortfolioCompare;
|
|
|
|
use App\Models\InventoryItem;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Services\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Support\Baselines\BaselineSubjectKey;
|
|
use App\Support\Baselines\SubjectClass;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\Resources\ResourceIdentity;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use InvalidArgumentException;
|
|
|
|
final class CrossEnvironmentComparePreviewBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly CurrentStateHashResolver $currentStateHashResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* selection: array{
|
|
* workspaceId: int,
|
|
* sourceEnvironmentId: int,
|
|
* sourceEnvironmentName: string,
|
|
* targetEnvironmentId: int,
|
|
* targetEnvironmentName: string,
|
|
* policyTypes: list<string>
|
|
* },
|
|
* summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int},
|
|
* subjects: list<array<string, mixed>>
|
|
* }
|
|
*/
|
|
public function build(CrossEnvironmentCompareSelection $selection): array
|
|
{
|
|
$sourceIndex = $this->indexEnvironmentSubjects($selection->sourceEnvironment, $selection->policyTypes);
|
|
$targetIndex = $this->indexEnvironmentSubjects($selection->targetEnvironment, $selection->policyTypes);
|
|
|
|
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceEnvironment, $sourceIndex['evidence_subjects']);
|
|
$targetEvidence = $this->resolvedEvidenceMap($selection->targetEnvironment, $targetIndex['evidence_subjects']);
|
|
|
|
$subjects = [];
|
|
$summary = [
|
|
'match' => 0,
|
|
'different' => 0,
|
|
'missing' => 0,
|
|
'ambiguous' => 0,
|
|
'blocked' => 0,
|
|
'total' => 0,
|
|
];
|
|
|
|
foreach ($sourceIndex['preview_subjects'] as $sourceSubject) {
|
|
$previewSubject = $this->buildPreviewSubject(
|
|
sourceSubject: $sourceSubject,
|
|
sourceEnvironment: $selection->sourceEnvironment,
|
|
targetEnvironment: $selection->targetEnvironment,
|
|
targetIndex: $targetIndex['subjects'],
|
|
sourceEvidence: $sourceEvidence,
|
|
targetEvidence: $targetEvidence,
|
|
);
|
|
|
|
$subjects[] = $previewSubject;
|
|
$summary[$previewSubject['state']]++;
|
|
$summary['total']++;
|
|
}
|
|
|
|
usort($subjects, function (array $left, array $right): int {
|
|
$policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? ''));
|
|
|
|
if ($policyTypeComparison !== 0) {
|
|
return $policyTypeComparison;
|
|
}
|
|
|
|
$displayNameComparison = strcmp(
|
|
Str::lower((string) ($left['displayName'] ?? '')),
|
|
Str::lower((string) ($right['displayName'] ?? '')),
|
|
);
|
|
|
|
if ($displayNameComparison !== 0) {
|
|
return $displayNameComparison;
|
|
}
|
|
|
|
return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? ''));
|
|
});
|
|
|
|
return [
|
|
'selection' => [
|
|
'workspaceId' => $selection->workspaceId(),
|
|
'sourceEnvironmentId' => $selection->sourceEnvironmentId(),
|
|
'sourceEnvironmentName' => (string) $selection->sourceEnvironment->name,
|
|
'targetEnvironmentId' => $selection->targetEnvironmentId(),
|
|
'targetEnvironmentName' => (string) $selection->targetEnvironment->name,
|
|
'policyTypes' => $selection->policyTypes,
|
|
],
|
|
'summary' => $summary,
|
|
'subjects' => $subjects,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param ManagedEnvironment $environment
|
|
* @param list<string> $policyTypes
|
|
* @return array{
|
|
* preview_subjects: list<array<string, mixed>>,
|
|
* evidence_subjects: list<array{policy_type: string, subject_external_id: string}>,
|
|
* subjects: array<string, array<string, mixed>>
|
|
* }
|
|
*/
|
|
private function indexEnvironmentSubjects(ManagedEnvironment $environment, array $policyTypes): array
|
|
{
|
|
$inventoryItems = InventoryItem::query()
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->when(
|
|
$policyTypes !== [],
|
|
fn ($query) => $query->whereIn('policy_type', $policyTypes),
|
|
)
|
|
->orderBy('policy_type')
|
|
->orderBy('display_name')
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
$subjects = [];
|
|
$previewSubjects = [];
|
|
$evidenceSubjects = [];
|
|
|
|
foreach ($inventoryItems as $inventoryItem) {
|
|
if (! $inventoryItem instanceof InventoryItem) {
|
|
continue;
|
|
}
|
|
|
|
$policyType = trim((string) $inventoryItem->policy_type);
|
|
$subjectKey = $this->canonicalSubjectKeyForInventoryItem($inventoryItem, $policyType);
|
|
|
|
$subjectRecord = $this->inventorySubjectRecord($environment, $inventoryItem, $policyType, $subjectKey);
|
|
|
|
if ($subjectKey === null) {
|
|
$previewSubjects[] = [
|
|
...$subjectRecord,
|
|
'resolution' => 'identifier_missing',
|
|
'duplicateCount' => 1,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$indexKey = $this->subjectIndexKey($policyType, $subjectKey);
|
|
|
|
if (! array_key_exists($indexKey, $subjects)) {
|
|
$subjects[$indexKey] = [
|
|
'policyType' => $policyType,
|
|
'subjectKey' => $subjectKey,
|
|
'displayName' => $subjectRecord['displayName'],
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
$subjects[$indexKey]['items'][] = $subjectRecord;
|
|
}
|
|
|
|
foreach ($subjects as $indexKey => $subjectGroup) {
|
|
$items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : [];
|
|
$firstItem = $items[0] ?? null;
|
|
|
|
if (! is_array($firstItem)) {
|
|
continue;
|
|
}
|
|
|
|
$previewSubjects[] = [
|
|
...$firstItem,
|
|
'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved',
|
|
'duplicateCount' => count($items),
|
|
];
|
|
|
|
if (count($items) === 1) {
|
|
$evidenceSubjects[] = [
|
|
'policy_type' => (string) $firstItem['policyType'],
|
|
'subject_external_id' => (string) $firstItem['subjectExternalId'],
|
|
];
|
|
}
|
|
|
|
$subjects[$indexKey]['representative'] = $firstItem;
|
|
$subjects[$indexKey]['duplicateCount'] = count($items);
|
|
}
|
|
|
|
return [
|
|
'preview_subjects' => $previewSubjects,
|
|
'evidence_subjects' => $evidenceSubjects,
|
|
'subjects' => $subjects,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array<string, mixed>> $targetIndex
|
|
* @param array<string, ResolvedEvidence|null> $sourceEvidence
|
|
* @param array<string, ResolvedEvidence|null> $targetEvidence
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPreviewSubject(
|
|
array $sourceSubject,
|
|
ManagedEnvironment $sourceEnvironment,
|
|
ManagedEnvironment $targetEnvironment,
|
|
array $targetIndex,
|
|
array $sourceEvidence,
|
|
array $targetEvidence,
|
|
): array {
|
|
$policyType = (string) ($sourceSubject['policyType'] ?? '');
|
|
$displayName = (string) ($sourceSubject['displayName'] ?? '');
|
|
$subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null;
|
|
$reasonCodes = [];
|
|
$state = 'blocked';
|
|
$trustLevel = 'unusable';
|
|
|
|
$sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject);
|
|
$targetEvidenceRecord = null;
|
|
$targetSubject = null;
|
|
|
|
if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') {
|
|
$reasonCodes[] = 'source_identifier_missing';
|
|
} elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') {
|
|
$state = 'ambiguous';
|
|
$trustLevel = 'diagnostic_only';
|
|
$reasonCodes[] = 'source_subject_ambiguous';
|
|
} elseif ($subjectKey !== null) {
|
|
$targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null;
|
|
|
|
if (! is_array($targetSubject)) {
|
|
$state = 'missing';
|
|
$trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence
|
|
&& $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
|
? 'trustworthy'
|
|
: 'limited_confidence';
|
|
$reasonCodes[] = 'target_subject_missing';
|
|
} elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) {
|
|
$state = 'ambiguous';
|
|
$trustLevel = 'diagnostic_only';
|
|
$reasonCodes[] = 'target_subject_ambiguous';
|
|
} else {
|
|
$representative = $targetSubject['representative'] ?? null;
|
|
|
|
if (is_array($representative)) {
|
|
$targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative);
|
|
}
|
|
|
|
if (! $sourceEvidenceRecord instanceof ResolvedEvidence) {
|
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
|
}
|
|
|
|
if (! $targetEvidenceRecord instanceof ResolvedEvidence) {
|
|
$reasonCodes[] = 'target_evidence_refresh_required';
|
|
}
|
|
|
|
if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) {
|
|
$state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different';
|
|
$trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
|
&& $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
|
? 'trustworthy'
|
|
: 'limited_confidence';
|
|
|
|
if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
|
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
|
}
|
|
|
|
if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
|
|
$reasonCodes[] = 'target_evidence_refresh_required';
|
|
}
|
|
} else {
|
|
$state = 'blocked';
|
|
$trustLevel = 'unusable';
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) {
|
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
|
}
|
|
|
|
if ($state === 'blocked' && $reasonCodes === []) {
|
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
|
}
|
|
|
|
$reasonCodes = array_values(array_unique($reasonCodes));
|
|
|
|
return [
|
|
'policyType' => $policyType,
|
|
'displayName' => $displayName,
|
|
'subjectKey' => $subjectKey,
|
|
'state' => $state,
|
|
'trustLevel' => $trustLevel,
|
|
'reasonCodes' => $reasonCodes,
|
|
'source' => $this->subjectSidePayload($sourceEnvironment, $sourceSubject, $sourceEvidenceRecord),
|
|
'target' => $this->subjectSidePayload(
|
|
$targetEnvironment,
|
|
is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null,
|
|
$targetEvidenceRecord,
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
|
* @return array<string, ResolvedEvidence|null>
|
|
*/
|
|
private function resolvedEvidenceMap(ManagedEnvironment $environment, array $subjects): array
|
|
{
|
|
return $this->currentStateHashResolver->resolveForSubjects($environment, $subjects);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $subject
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function subjectSidePayload(ManagedEnvironment $environment, ?array $subject, ?ResolvedEvidence $evidence): array
|
|
{
|
|
return [
|
|
'environmentId' => (int) $environment->getKey(),
|
|
'environmentName' => (string) $environment->name,
|
|
'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null,
|
|
'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null,
|
|
'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null,
|
|
'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null,
|
|
'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null,
|
|
'evidence' => $this->evidencePayload($evidence),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* policyType: string,
|
|
* displayName: string,
|
|
* subjectKey: ?string,
|
|
* inventoryItemId: int,
|
|
* subjectExternalId: string,
|
|
* lastSeenAt: ?string
|
|
* }
|
|
*/
|
|
private function inventorySubjectRecord(ManagedEnvironment $environment, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array
|
|
{
|
|
$displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : '';
|
|
$displayName = $displayName !== ''
|
|
? $displayName
|
|
: ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType));
|
|
|
|
return [
|
|
'environmentId' => (int) $environment->getKey(),
|
|
'policyType' => $policyType,
|
|
'displayName' => $displayName,
|
|
'subjectKey' => $subjectKey,
|
|
'inventoryItemId' => (int) $inventoryItem->getKey(),
|
|
'subjectExternalId' => (string) $inventoryItem->external_id,
|
|
'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, ResolvedEvidence|null> $evidenceMap
|
|
*/
|
|
private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence
|
|
{
|
|
$policyType = trim((string) ($subject['policyType'] ?? ''));
|
|
$subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? ''));
|
|
|
|
if ($policyType === '' || $subjectExternalId === '') {
|
|
return null;
|
|
}
|
|
|
|
$key = $policyType.'|'.$subjectExternalId;
|
|
$evidence = $evidenceMap[$key] ?? null;
|
|
|
|
return $evidence instanceof ResolvedEvidence ? $evidence : null;
|
|
}
|
|
|
|
private function canonicalSubjectKeyForInventoryItem(InventoryItem $inventoryItem, string $policyType): ?string
|
|
{
|
|
$externalId = is_string($inventoryItem->external_id) ? trim($inventoryItem->external_id) : '';
|
|
|
|
if ($externalId === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$identity = ResourceIdentity::providerResource('inventory', $policyType, $externalId);
|
|
} catch (InvalidArgumentException) {
|
|
return null;
|
|
}
|
|
|
|
return BaselineSubjectKey::forProviderResourceIdentity(
|
|
subjectDomain: 'baseline',
|
|
subjectClass: InventoryPolicyTypeMeta::isFoundation($policyType)
|
|
? SubjectClass::FoundationBacked
|
|
: SubjectClass::PolicyBacked,
|
|
subjectTypeKey: $policyType,
|
|
identity: $identity,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* hash: string,
|
|
* fidelity: string,
|
|
* source: string,
|
|
* observedAt: ?string,
|
|
* policyVersionId: ?int,
|
|
* operationRunId: ?int,
|
|
* capturePurpose: ?string
|
|
* }|null
|
|
*/
|
|
private function evidencePayload(?ResolvedEvidence $evidence): ?array
|
|
{
|
|
if (! $evidence instanceof ResolvedEvidence) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'hash' => $evidence->hash,
|
|
'fidelity' => $evidence->fidelity,
|
|
'source' => $evidence->source,
|
|
'observedAt' => $evidence->observedAt?->toIso8601String(),
|
|
'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null)
|
|
? (int) $evidence->meta['policy_version_id']
|
|
: null,
|
|
'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null)
|
|
? (int) $evidence->meta['operation_run_id']
|
|
: null,
|
|
'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null)
|
|
? (string) $evidence->meta['capture_purpose']
|
|
: null,
|
|
];
|
|
}
|
|
|
|
private function subjectIndexKey(string $policyType, string $subjectKey): string
|
|
{
|
|
return $policyType.'|'.$subjectKey;
|
|
}
|
|
}
|