Some checks failed
Main Confidence / confidence (push) Failing after 54s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`. Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request. This PR was created by agent on user request; do not merge automatically. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #308
417 lines
16 KiB
PHP
417 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\PortfolioCompare;
|
|
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Tenant;
|
|
use App\Services\Baselines\CurrentStateHashResolver;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Support\Baselines\BaselineSubjectKey;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class CrossTenantComparePreviewBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly CurrentStateHashResolver $currentStateHashResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* selection: array{
|
|
* workspaceId: int,
|
|
* sourceTenantId: int,
|
|
* sourceTenantName: string,
|
|
* targetTenantId: int,
|
|
* targetTenantName: 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(CrossTenantCompareSelection $selection): array
|
|
{
|
|
$sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes);
|
|
$targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes);
|
|
|
|
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']);
|
|
$targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $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,
|
|
sourceTenant: $selection->sourceTenant,
|
|
targetTenant: $selection->targetTenant,
|
|
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(),
|
|
'sourceTenantId' => $selection->sourceTenantId(),
|
|
'sourceTenantName' => (string) $selection->sourceTenant->name,
|
|
'targetTenantId' => $selection->targetTenantId(),
|
|
'targetTenantName' => (string) $selection->targetTenant->name,
|
|
'policyTypes' => $selection->policyTypes,
|
|
],
|
|
'summary' => $summary,
|
|
'subjects' => $subjects,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Tenant $tenant
|
|
* @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 indexTenantSubjects(Tenant $tenant, array $policyTypes): array
|
|
{
|
|
$inventoryItems = InventoryItem::query()
|
|
->where('tenant_id', (int) $tenant->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 = BaselineSubjectKey::forPolicy(
|
|
$policyType,
|
|
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
|
|
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
|
|
);
|
|
|
|
$subjectRecord = $this->inventorySubjectRecord($tenant, $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,
|
|
Tenant $sourceTenant,
|
|
Tenant $targetTenant,
|
|
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($sourceTenant, $sourceSubject, $sourceEvidenceRecord),
|
|
'target' => $this->subjectSidePayload(
|
|
$targetTenant,
|
|
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(Tenant $tenant, array $subjects): array
|
|
{
|
|
return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $subject
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array
|
|
{
|
|
return [
|
|
'tenantId' => (int) $tenant->getKey(),
|
|
'tenantName' => (string) $tenant->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(Tenant $tenant, 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 [
|
|
'tenantId' => (int) $tenant->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;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|