TenantAtlas/app/Services/Baselines/Evidence/ContentEvidenceProvider.php
Ahmed Darrazi 39fd8ca1ea feat(spec-119): baseline compare drift cutover
- Enrich drift findings evidence_jsonb for diff UX (summary.kind, refs, fidelity, provenance)

- Add baseline policy version resolver and contract asserts

- Remove legacy drift generator + DriftLanding surfaces

- Add one-time cleanup migration for legacy drift findings

- Scope baseline capture/landing warnings to latest inventory sync

- Canonicalize compliance scheduledActionsForRule drift signal
2026-03-06 15:22:42 +01:00

255 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Baselines\Evidence;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Baselines\CurrentStateEvidenceProvider;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class ContentEvidenceProvider implements CurrentStateEvidenceProvider
{
public function __construct(
private readonly DriftHasher $hasher,
private readonly SettingsNormalizer $settingsNormalizer,
private readonly AssignmentsNormalizer $assignmentsNormalizer,
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
) {}
public function name(): string
{
return 'policy_version';
}
public function fromPolicyVersion(
PolicyVersion $version,
string $subjectExternalId,
?CarbonImmutable $observedAt = null,
?int $observedOperationRunId = null,
): ResolvedEvidence {
return $this->buildResolvedEvidence(
policyType: (string) $version->policy_type,
subjectExternalId: $subjectExternalId,
platform: is_string($version->platform) ? $version->platform : null,
snapshot: $version->snapshot,
assignments: $version->assignments,
scopeTags: $version->scope_tags,
capturedAt: $version->captured_at,
policyVersionId: (int) $version->getKey(),
operationRunId: $version->operation_run_id,
capturePurpose: $version->capture_purpose?->value,
observedAt: $observedAt,
observedOperationRunId: $observedOperationRunId,
);
}
public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since = null, ?int $latestInventorySyncRunId = null): array
{
if ($subjects === []) {
return [];
}
$requestedKeys = $this->requestedKeys($subjects);
if ($requestedKeys === []) {
return [];
}
$policyTypes = array_values(array_unique(array_map(
static fn (array $subject): string => trim((string) ($subject['policy_type'] ?? '')),
$subjects,
)));
$policyTypes = array_values(array_filter($policyTypes, static fn (string $value): bool => $value !== ''));
$externalIds = array_values(array_unique(array_map(
static fn (array $subject): string => trim((string) ($subject['subject_external_id'] ?? '')),
$subjects,
)));
$externalIds = array_values(array_filter($externalIds, static fn (string $value): bool => $value !== ''));
if ($policyTypes === [] || $externalIds === []) {
return [];
}
$policies = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('policy_type', $policyTypes)
->whereIn('external_id', $externalIds)
->get(['id', 'policy_type', 'external_id']);
/** @var Collection<int, Policy> $policies */
$policyIdToKey = [];
$policyIds = [];
foreach ($policies as $policy) {
if (! $policy instanceof Policy) {
continue;
}
$key = (string) $policy->policy_type.'|'.(string) $policy->external_id;
if (! array_key_exists($key, $requestedKeys)) {
continue;
}
$policyIdToKey[(int) $policy->getKey()] = $key;
$policyIds[] = (int) $policy->getKey();
}
if ($policyIds === []) {
return [];
}
$baseQuery = DB::table('policy_versions')
->select([
'policy_versions.id',
'policy_versions.operation_run_id',
'policy_versions.capture_purpose',
'policy_versions.policy_id',
'policy_versions.policy_type',
'policy_versions.platform',
'policy_versions.captured_at',
'policy_versions.snapshot',
'policy_versions.assignments',
'policy_versions.scope_tags',
'policy_versions.version_number',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY policy_id ORDER BY captured_at DESC, version_number DESC, id DESC) as rn')
->where('tenant_id', (int) $tenant->getKey())
->whereIn('policy_id', $policyIds)
->whereNull('deleted_at');
if ($since instanceof CarbonImmutable) {
$baseQuery->where('captured_at', '>=', $since->toDateTimeString());
}
$versions = DB::query()
->fromSub($baseQuery, 'ranked_policy_versions')
->where('rn', 1)
->get();
$resolved = [];
foreach ($versions as $version) {
$policyId = is_numeric($version->policy_id ?? null) ? (int) $version->policy_id : null;
$key = $policyId !== null ? ($policyIdToKey[$policyId] ?? null) : null;
if (! is_string($key) || $key === '' || ! array_key_exists($key, $requestedKeys)) {
continue;
}
$policyType = is_string($version->policy_type ?? null) ? (string) $version->policy_type : '';
$subjectExternalId = (string) ($requestedKeys[$key] ?? '');
if ($policyType === '' || $subjectExternalId === '') {
continue;
}
$resolved[$key] = $this->buildResolvedEvidence(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
platform: is_string($version->platform ?? null) ? (string) $version->platform : null,
snapshot: $version->snapshot ?? null,
assignments: $version->assignments ?? null,
scopeTags: $version->scope_tags ?? null,
capturedAt: $version->captured_at ?? null,
policyVersionId: is_numeric($version->id ?? null) ? (int) $version->id : null,
operationRunId: is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null,
capturePurpose: is_string($version->capture_purpose ?? null) ? trim((string) $version->capture_purpose) : null,
);
}
return $resolved;
}
/**
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @return array<string, string>
*/
private function requestedKeys(array $subjects): array
{
$keys = [];
foreach ($subjects as $subject) {
$policyType = trim((string) ($subject['policy_type'] ?? ''));
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
if ($policyType === '' || $externalId === '') {
continue;
}
$keys[$policyType.'|'.$externalId] = $externalId;
}
return $keys;
}
private function buildResolvedEvidence(
string $policyType,
string $subjectExternalId,
?string $platform,
mixed $snapshot,
mixed $assignments,
mixed $scopeTags,
mixed $capturedAt,
?int $policyVersionId,
mixed $operationRunId,
?string $capturePurpose,
?CarbonImmutable $observedAt = null,
?int $observedOperationRunId = null,
): ResolvedEvidence {
$snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null);
$snapshot = is_array($snapshot) ? $snapshot : [];
$assignments = is_array($assignments) ? $assignments : (is_string($assignments) ? json_decode($assignments, true) : null);
$assignments = is_array($assignments) ? $assignments : [];
$scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null);
$scopeTags = is_array($scopeTags) ? $scopeTags : [];
$normalized = $this->settingsNormalizer->normalizeForDiff(
snapshot: $snapshot,
policyType: $policyType,
platform: $platform,
);
$normalizedAssignments = $this->assignmentsNormalizer->normalizeForDiff($assignments);
$normalizedScopeTagIds = $this->scopeTagsNormalizer->normalizeIds($scopeTags);
$hash = $this->hasher->hashNormalized([
'settings' => $normalized,
'assignments' => $normalizedAssignments,
'scope_tag_ids' => $normalizedScopeTagIds,
]);
$observedAt ??= is_string($capturedAt) ? CarbonImmutable::parse($capturedAt) : null;
$observedOperationRunId ??= is_numeric($operationRunId) ? (int) $operationRunId : null;
$capturePurpose = is_string($capturePurpose) ? trim($capturePurpose) : null;
$capturePurpose = $capturePurpose !== '' ? $capturePurpose : null;
return new ResolvedEvidence(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
hash: $hash,
fidelity: EvidenceProvenance::FidelityContent,
source: EvidenceProvenance::SourcePolicyVersion,
observedAt: $observedAt,
observedOperationRunId: $observedOperationRunId,
meta: [
'policy_version_id' => $policyVersionId,
'operation_run_id' => is_numeric($operationRunId) ? (int) $operationRunId : null,
'capture_purpose' => $capturePurpose,
],
);
}
}