TenantAtlas/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php
ahmido da1adbdeb5 Spec 119: Drift cutover to Baseline Compare (golden master) (#144)
Implements Spec 119 (Drift Golden Master Cutover):

- Baseline Compare is the only drift writer (`source = baseline.compare`).
- Drift findings now store diff-compatible `evidence_jsonb` (summary.kind, baseline/current policy_version_id refs, fidelity + provenance).
- Findings UI renders one-sided diffs for `missing_policy`/`unexpected_policy` when a single ref exists; otherwise shows explicit “diff unavailable”.
- Removes legacy drift generator runtime (jobs/services/UI) and related tests.
- Adds one-time migration to delete legacy drift findings (`finding_type=drift` where source is null or != baseline.compare).
- Scopes baseline capture & landing duplicate warnings to latest completed inventory sync.
- Canonicalizes compliance `scheduledActionsForRule` drift signal and keeps legacy snapshots comparable.

Tests:
- `vendor/bin/sail artisan test --compact` (full suite per tasks)
- Focused pack: BaselinePolicyVersionResolverTest, BaselineCompareDriftEvidenceContractTest, DriftFindingDiffUnavailableTest, LegacyDriftFindingsCleanupMigrationTest, ComplianceNoncomplianceActionsDriftTest

Notes:
- Livewire v4+ / Filament v5 compatible (no legacy APIs).
- No new external dependencies.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #144
2026-03-06 14:30:49 +00:00

144 lines
3.9 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\Support\Baselines\BaselineSubjectKey;
use Carbon\CarbonImmutable;
use Throwable;
final class BaselinePolicyVersionResolver
{
/**
* Cached map of (tenant_id, policy_type) => subject_key => policy_id.
*
* @var array<int, array<string, array<string, int>>>
*/
private array $policyIdIndex = [];
public function resolve(
Tenant $tenant,
string $policyType,
string $subjectKey,
?string $observedAt,
): ?int {
$tenantId = (int) $tenant->getKey();
$policyType = trim($policyType);
$subjectKey = trim($subjectKey);
if ($tenantId <= 0 || $policyType === '' || $subjectKey === '') {
return null;
}
$observedAtCarbon = $this->parseObservedAt($observedAt);
if (! $observedAtCarbon instanceof CarbonImmutable) {
return null;
}
$policyId = $this->resolvePolicyId($tenantId, $policyType, $subjectKey);
if ($policyId === null) {
return null;
}
$rangeStart = $observedAtCarbon;
$rangeEnd = $observedAtCarbon->addSecond();
$versionId = PolicyVersion::query()
->where('tenant_id', $tenantId)
->where('policy_id', $policyId)
->whereNull('deleted_at')
->where('captured_at', '>=', $rangeStart)
->where('captured_at', '<', $rangeEnd)
->orderByDesc('captured_at')
->orderByDesc('version_number')
->orderByDesc('id')
->value('id');
return is_numeric($versionId) ? (int) $versionId : null;
}
private function parseObservedAt(?string $observedAt): ?CarbonImmutable
{
if (! is_string($observedAt)) {
return null;
}
$observedAt = trim($observedAt);
if ($observedAt === '') {
return null;
}
try {
return CarbonImmutable::parse($observedAt);
} catch (Throwable) {
return null;
}
}
private function resolvePolicyId(int $tenantId, string $policyType, string $subjectKey): ?int
{
if (! array_key_exists($tenantId, $this->policyIdIndex) || ! array_key_exists($policyType, $this->policyIdIndex[$tenantId])) {
$this->policyIdIndex[$tenantId][$policyType] = $this->buildIndex($tenantId, $policyType);
}
$policyId = $this->policyIdIndex[$tenantId][$policyType][$subjectKey] ?? null;
return is_numeric($policyId) ? (int) $policyId : null;
}
/**
* Build a subject_key => policy_id map for a given tenant + policy_type.
*
* If multiple policies map to the same subject_key, that key is treated as ambiguous and excluded.
*
* @return array<string, int>
*/
private function buildIndex(int $tenantId, string $policyType): array
{
$policies = Policy::query()
->where('tenant_id', $tenantId)
->where('policy_type', $policyType)
->get(['id', 'display_name']);
/** @var array<string, int> $index */
$index = [];
/** @var array<string, true> $ambiguous */
$ambiguous = [];
foreach ($policies as $policy) {
if (! $policy instanceof Policy) {
continue;
}
$key = BaselineSubjectKey::fromDisplayName($policy->display_name);
if ($key === null) {
continue;
}
if (array_key_exists($key, $index)) {
$ambiguous[$key] = true;
continue;
}
$index[$key] = (int) $policy->getKey();
}
foreach (array_keys($ambiguous) as $key) {
unset($index[$key]);
}
return $index;
}
}