TenantAtlas/apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php
ahmido ad16eee591 Spec 204: harden platform core vocabulary (#234)
## Summary
- add the Spec 204 platform vocabulary foundation, including canonical glossary terms, registry ownership descriptors, canonical operation type and alias resolution, and explicit reason ownership and platform reason-family metadata
- harden platform-facing compare, snapshot, evidence, monitoring, review, and reporting surfaces so they prefer governed-subject and canonical operation semantics while preserving intentional Intune-owned terminology
- extend Spec 204 unit, feature, Filament, and architecture coverage and add the full spec artifacts, checklist, and completed task ledger

## Verification
- ran the focused recent-change Sail verification pack for the new glossary and reason-semantics work
- ran the full Spec 204 quickstart verification pack under Sail
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- ran an integrated-browser smoke pass covering tenant dashboard, operations, operation detail, baseline compare, evidence, reviews, review packs, provider connections, inventory items, backup schedules, onboarding, and the system dashboard/operations/failures/run-detail surfaces

## Notes
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new destructive actions or asset-registration changes are introduced by this branch

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #234
2026-04-14 06:09:42 +00:00

1022 lines
46 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use App\Models\Finding;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Baselines\SubjectResolver;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final class IntuneCompareStrategy implements CompareStrategy
{
public function __construct(
private readonly BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
private readonly DriftHasher $hasher,
private readonly SettingsNormalizer $settingsNormalizer,
private readonly AssignmentsNormalizer $assignmentsNormalizer,
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
private readonly ContentEvidenceProvider $contentEvidenceProvider,
private readonly IntuneRoleDefinitionNormalizer $roleDefinitionNormalizer,
private readonly SubjectResolver $subjectResolver,
) {}
public function key(): CompareStrategyKey
{
return CompareStrategyKey::intunePolicy();
}
public function capabilities(): array
{
return [
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::Intune->value],
subjectClasses: [GovernanceSubjectClass::Policy->value],
subjectTypeKeys: 'all',
),
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::PlatformFoundation->value],
subjectClasses: [GovernanceSubjectClass::ConfigurationResource->value],
subjectTypeKeys: 'all',
),
];
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
$subjectResults = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$inventorySyncRunId = $context->inventorySyncRunId() ?? 0;
$baselinePlaceholderProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentMissingProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $inventorySyncRunId > 0 ? $inventorySyncRunId : null,
);
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
$policyType = (string) ($baselineItem['policy_type'] ?? '');
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
tenant: $tenant,
baselineItem: $baselineItem,
baselineProvenance: $baselineProvenance,
);
$baselineComparableHash = $this->effectiveBaselineHash(
tenant: $tenant,
baselineItem: $baselineItem,
baselinePolicyVersionId: $baselinePolicyVersionId,
);
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel($baselineItem, null),
compareState: CompareState::Incomplete,
reasonCode: 'missing_role_definition_baseline_version_reference',
baselineAvailability: 'available',
currentStateAvailability: 'missing',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: 'missing',
);
continue;
}
$evidence = $this->buildDriftEvidenceContract(
changeType: 'missing_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $this->operatorLabel($baselineItem, null),
baselineHash: $baselineComparableHash,
currentHash: null,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentMissingProvenance,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
summaryKind: 'policy_snapshot',
baselineProfileId: $context->baselineProfileId,
baselineSnapshotId: $context->baselineSnapshotId,
compareOperationRunId: $context->operationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: [],
diffKind: 'missing',
);
$rbacRoleDefinitionSummary['missing']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$subjectResults[] = $this->driftResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null,
operatorLabel: $this->operatorLabel($baselineItem, null),
changeType: 'missing_policy',
severity: $isRbacRoleDefinition
? Finding::SEVERITY_HIGH
: $this->severityForChangeType($severityMapping, 'missing_policy'),
evidence: $evidence,
baselineAvailability: 'available',
currentStateAvailability: 'missing',
);
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: 'missing',
);
continue;
}
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($baselineComparableHash !== $currentEvidence->hash) {
$roleDefinitionDiff = null;
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_role_definition_baseline_version_reference',
baselineAvailability: 'available',
currentStateAvailability: 'available',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: $currentEvidence->fidelity,
);
continue;
}
if ($currentPolicyVersionId === null) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_role_definition_current_version_reference',
baselineAvailability: 'available',
currentStateAvailability: 'available',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: $currentEvidence->fidelity,
);
continue;
}
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
);
if (! is_array($roleDefinitionDiff)) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_role_definition_compare_surface',
baselineAvailability: 'available',
currentStateAvailability: 'available',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: $currentEvidence->fidelity,
);
continue;
}
}
$summaryKind = $isRbacRoleDefinition
? 'rbac_role_definition'
: $this->selectSummaryKind(
tenant: $tenant,
policyType: $policyType,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
);
$evidence = $this->buildDriftEvidenceContract(
changeType: 'different_version',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $this->operatorLabel($baselineItem, $currentItem),
baselineHash: $baselineComparableHash,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: $summaryKind,
baselineProfileId: $context->baselineProfileId,
baselineSnapshotId: $context->baselineSnapshotId,
compareOperationRunId: $context->operationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: (string) $roleDefinitionDiff['diff_kind'],
roleDefinitionDiff: $roleDefinitionDiff,
);
$rbacRoleDefinitionSummary['modified']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$subjectResults[] = $this->driftResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
subjectType: is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null,
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
changeType: 'different_version',
severity: $isRbacRoleDefinition
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
: $this->severityForChangeType($severityMapping, 'different_version'),
evidence: $evidence,
baselineAvailability: 'available',
currentStateAvailability: 'available',
);
continue;
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unchanged']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$subjectResults[] = new CompareSubjectResult(
subjectIdentity: $this->subjectIdentity($policyType, (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''), $subjectKey),
projection: $this->subjectProjection($policyType, $this->operatorLabel($baselineItem, $currentItem), is_string($baselineItem['subject_type'] ?? null) ? (string) $baselineItem['subject_type'] : null, 'policy_snapshot'),
baselineAvailability: 'available',
currentStateAvailability: 'available',
compareState: CompareState::NoDrift,
trustLevel: $currentEvidence->fidelity === EvidenceProvenance::FidelityContent
? TrustworthinessLevel::Trustworthy->value
: TrustworthinessLevel::LimitedConfidence->value,
evidenceQuality: $currentEvidence->fidelity,
diagnostics: [
'strategy_key' => $this->key()->value,
],
);
}
foreach ($currentItems as $key => $currentItem) {
if (array_key_exists($key, $baselineItems)) {
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
$policyType = (string) ($currentItem['policy_type'] ?? '');
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel(null, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'missing',
currentStateAvailability: 'unknown',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: 'missing',
);
continue;
}
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$subjectResults[] = $this->gapResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
operatorLabel: $this->operatorLabel(null, $currentItem),
compareState: CompareState::Incomplete,
reasonCode: 'missing_role_definition_current_version_reference',
baselineAvailability: 'missing',
currentStateAvailability: 'available',
trustLevel: TrustworthinessLevel::Unusable->value,
evidenceQuality: $currentEvidence->fidelity,
);
continue;
}
$evidence = $this->buildDriftEvidenceContract(
changeType: 'unexpected_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $this->operatorLabel(null, $currentItem),
baselineHash: null,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselinePlaceholderProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: 'policy_snapshot',
baselineProfileId: $context->baselineProfileId,
baselineSnapshotId: $context->baselineSnapshotId,
compareOperationRunId: $context->operationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: 'unexpected',
);
$rbacRoleDefinitionSummary['unexpected']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$subjectResults[] = $this->driftResult(
policyType: $policyType,
subjectKey: $subjectKey,
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
subjectType: null,
operatorLabel: $this->operatorLabel(null, $currentItem),
changeType: 'unexpected_policy',
severity: $isRbacRoleDefinition
? Finding::SEVERITY_MEDIUM
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
evidence: $evidence,
baselineAvailability: 'missing',
currentStateAvailability: 'available',
);
}
return [
'subject_results' => $subjectResults,
'diagnostics' => [
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
],
];
}
/**
* @param array<string, mixed> $baselineItem
* @param array<string, mixed>|null $currentItem
* @param array<string, mixed> $evidence
*/
private function driftResult(
string $policyType,
string $subjectKey,
string $subjectExternalId,
?string $subjectType,
string $operatorLabel,
string $changeType,
string $severity,
array $evidence,
string $baselineAvailability,
string $currentStateAvailability,
): CompareSubjectResult {
$fidelity = is_string($evidence['fidelity'] ?? null) && trim((string) $evidence['fidelity']) !== ''
? trim((string) $evidence['fidelity'])
: EvidenceProvenance::FidelityMeta;
return new CompareSubjectResult(
subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey),
projection: $this->subjectProjection(
policyType: $policyType,
operatorLabel: $operatorLabel,
subjectType: $subjectType,
summaryKind: is_string(data_get($evidence, 'summary.kind')) ? (string) data_get($evidence, 'summary.kind') : null,
),
baselineAvailability: $baselineAvailability,
currentStateAvailability: $currentStateAvailability,
compareState: CompareState::Drift,
trustLevel: $fidelity === EvidenceProvenance::FidelityContent
? TrustworthinessLevel::Trustworthy->value
: TrustworthinessLevel::LimitedConfidence->value,
evidenceQuality: $fidelity,
severityRecommendation: $severity,
findingCandidate: new CompareFindingCandidate(
changeType: $changeType,
severity: $severity,
fingerprintBasis: [
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'change_type' => $changeType,
],
evidencePayload: $evidence,
),
diagnostics: [
'strategy_key' => $this->key()->value,
],
);
}
private function gapResult(
string $policyType,
string $subjectKey,
string $subjectExternalId,
string $operatorLabel,
CompareState $compareState,
string $reasonCode,
string $baselineAvailability,
string $currentStateAvailability,
string $trustLevel,
string $evidenceQuality,
): CompareSubjectResult {
$descriptor = $this->subjectResolver->describeForCompare(
policyType: $policyType,
subjectExternalId: $subjectExternalId !== '' ? $subjectExternalId : null,
subjectKey: $subjectKey,
);
$outcome = match ($reasonCode) {
'missing_current' => $this->subjectResolver->missingExpectedRecord($descriptor),
'ambiguous_match' => $this->subjectResolver->ambiguousMatch($descriptor),
default => $this->subjectResolver->captureFailed($descriptor),
};
$gapRecord = array_merge($descriptor->toArray(), $outcome->toArray(), [
'reason_code' => $reasonCode,
'search_text' => strtolower(implode(' ', array_filter([$policyType, $subjectKey, $reasonCode, $operatorLabel]))),
]);
return new CompareSubjectResult(
subjectIdentity: $this->subjectIdentity($policyType, $subjectExternalId, $subjectKey),
projection: $this->subjectProjection($policyType, $operatorLabel, null, null),
baselineAvailability: $baselineAvailability,
currentStateAvailability: $currentStateAvailability,
compareState: $compareState,
trustLevel: $trustLevel,
evidenceQuality: $evidenceQuality,
diagnostics: [
'strategy_key' => $this->key()->value,
'reason_code' => $reasonCode,
'gap_record' => $gapRecord,
],
);
}
private function subjectIdentity(string $policyType, string $subjectExternalId, string $subjectKey): CompareSubjectIdentity
{
return new CompareSubjectIdentity(
domainKey: $this->domainKeyFor($policyType),
subjectClass: $this->subjectClassFor($policyType),
subjectTypeKey: $policyType,
externalSubjectId: $subjectExternalId !== '' ? $subjectExternalId : null,
subjectKey: $subjectKey,
);
}
private function subjectProjection(string $policyType, string $operatorLabel, ?string $subjectType, ?string $summaryKind): CompareSubjectProjection
{
return new CompareSubjectProjection(
platformSubjectClass: $this->platformSubjectClassFor($policyType, $subjectType),
domainKey: $this->domainKeyFor($policyType),
subjectTypeKey: $policyType,
operatorLabel: $operatorLabel,
summaryKind: $summaryKind,
additionalLabels: [
'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType,
'governed_subject_label' => (string) data_get($this->subjectDescriptor($policyType), 'display_label', $policyType),
],
subjectDescriptor: $this->subjectDescriptor($policyType),
);
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private function domainKeyFor(string $policyType): string
{
return InventoryPolicyTypeMeta::isFoundation($policyType)
? GovernanceDomainKey::PlatformFoundation->value
: GovernanceDomainKey::Intune->value;
}
private function subjectClassFor(string $policyType): string
{
return InventoryPolicyTypeMeta::isFoundation($policyType)
? GovernanceSubjectClass::ConfigurationResource->value
: GovernanceSubjectClass::Policy->value;
}
private function platformSubjectClassFor(string $policyType, ?string $subjectType): string
{
if (is_string($subjectType) && trim($subjectType) !== '') {
return trim($subjectType);
}
return InventoryPolicyTypeMeta::isFoundation($policyType)
? GovernanceSubjectClass::ConfigurationResource->value
: 'policy';
}
/**
* @param array<string, mixed>|null $baselineItem
* @param array<string, mixed>|null $currentItem
*/
private function operatorLabel(?array $baselineItem, ?array $currentItem): string
{
$displayName = $currentItem['meta_jsonb']['display_name']
?? $baselineItem['meta_jsonb']['display_name']
?? $currentItem['subject_key']
?? $baselineItem['subject_key']
?? 'Unknown subject';
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : 'Unknown subject';
}
private function effectiveBaselineHash(Tenant $tenant, array $baselineItem, ?int $baselinePolicyVersionId): string
{
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
if ($baselinePolicyVersionId === null) {
return $storedHash;
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion) {
return $storedHash;
}
return $this->contentEvidenceProvider->fromPolicyVersion(
version: $baselineVersion,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
)->hash;
}
private function resolveBaselinePolicyVersionId(Tenant $tenant, array $baselineItem, array $baselineProvenance): ?int
{
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
if (is_numeric($versionReferenceId)) {
return (int) $versionReferenceId;
}
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
return null;
}
$observedAt = $baselineProvenance['observed_at'] ?? null;
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
if (! is_string($observedAt) || $observedAt === '') {
return null;
}
return $this->baselinePolicyVersionResolver->resolve(
tenant: $tenant,
policyType: (string) ($baselineItem['policy_type'] ?? ''),
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
observedAt: $observedAt,
);
}
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
{
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
}
private function selectSummaryKind(Tenant $tenant, string $policyType, ?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
{
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
return 'policy_snapshot';
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
$currentVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return 'policy_snapshot';
}
$platform = is_string($baselineVersion->platform ?? null)
? (string) $baselineVersion->platform
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
$baselineSnapshotHash = $this->hasher->hashNormalized([
'settings' => $this->settingsNormalizer->normalizeForDiff(
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
policyType: $policyType,
platform: $platform,
),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
]);
$currentSnapshotHash = $this->hasher->hashNormalized([
'settings' => $this->settingsNormalizer->normalizeForDiff(
snapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
policyType: $policyType,
platform: $platform,
),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
]);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
return 'policy_snapshot';
}
$baselineAssignmentsHash = $this->hasher->hashNormalized([
'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
]);
$currentAssignmentsHash = $this->hasher->hashNormalized([
'assignments' => $this->assignmentsNormalizer->normalizeForDiff(is_array($currentVersion->assignments) ? $currentVersion->assignments : []),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
]);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
return 'policy_assignments';
}
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
return 'policy_snapshot';
}
$baselineScopeTagsHash = $this->hasher->hashNormalized([
'scope_tag_ids' => $baselineScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
]);
$currentScopeTagsHash = $this->hasher->hashNormalized([
'scope_tag_ids' => $currentScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
]);
return $baselineScopeTagsHash !== $currentScopeTagsHash
? 'policy_scope_tags'
: 'policy_snapshot';
}
private function severityForChangeType(array $severityMapping, string $changeType): string
{
$severity = $severityMapping[$changeType] ?? null;
if (! is_string($severity) || $severity === '') {
return match ($changeType) {
'missing_policy' => Finding::SEVERITY_HIGH,
'different_version' => Finding::SEVERITY_MEDIUM,
default => Finding::SEVERITY_LOW,
};
}
return $severity;
}
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
private function buildDriftEvidenceContract(
string $changeType,
string $policyType,
string $subjectKey,
?string $displayName,
?string $baselineHash,
?string $currentHash,
array $baselineProvenance,
array $currentProvenance,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
string $summaryKind,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
): array {
return [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $displayName,
'summary' => [
'kind' => $summaryKind,
],
'baseline' => [
'policy_version_id' => $baselinePolicyVersionId,
'hash' => $baselineHash,
'provenance' => $baselineProvenance,
],
'current' => [
'policy_version_id' => $currentPolicyVersionId,
'hash' => $currentHash,
'provenance' => $currentProvenance,
],
'fidelity' => $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId),
'provenance' => [
'baseline_profile_id' => $baselineProfileId,
'baseline_snapshot_id' => $baselineSnapshotId,
'compare_operation_run_id' => $compareOperationRunId,
'inventory_sync_run_id' => $inventorySyncRunId,
],
];
}
private function buildRoleDefinitionEvidencePayload(
Tenant $tenant,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
array $baselineMeta,
array $currentMeta,
string $diffKind,
?array $roleDefinitionDiff = null,
): array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
? $roleDefinitionDiff['baseline']
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
? $roleDefinitionDiff['current']
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
: $this->roleDefinitionPermissionKeys($changedKeys);
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
? (string) $roleDefinitionDiff['diff_kind']
: $diffKind;
return [
'diff_kind' => $resolvedDiffKind,
'diff_fingerprint' => is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
? (string) $roleDefinitionDiff['diff_fingerprint']
: hash(
'sha256',
json_encode([
'diff_kind' => $resolvedDiffKind,
'changed_keys' => $changedKeys,
'baseline' => $baselineNormalized,
'current' => $currentNormalized,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
),
'changed_keys' => $changedKeys,
'metadata_keys' => $metadataKeys,
'permission_keys' => $permissionKeys,
'baseline' => [
'normalized' => $baselineNormalized,
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
],
'current' => [
'normalized' => $currentNormalized,
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
],
];
}
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
{
if ($policyVersionId === null) {
return null;
}
return PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($policyVersionId);
}
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
{
if ($version instanceof PolicyVersion) {
return $this->roleDefinitionNormalizer->buildEvidenceMap(
is_array($version->snapshot) ? $version->snapshot : [],
is_string($version->platform ?? null) ? (string) $version->platform : null,
);
}
$normalized = [];
$displayName = $meta['display_name'] ?? null;
if (is_string($displayName) && trim($displayName) !== '') {
$normalized['Role definition > Display name'] = trim($displayName);
}
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
if (is_bool($isBuiltIn)) {
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
}
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
if (is_numeric($rolePermissionCount)) {
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
}
return $normalized;
}
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
{
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
sort($keys, SORT_STRING);
return array_values(array_filter(
$keys,
fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null),
));
}
private function roleDefinitionPermissionKeys(array $keys): array
{
return array_values(array_filter(
$keys,
static fn (string $key): bool => str_starts_with($key, 'Permission block '),
));
}
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
{
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
return 'content';
}
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
return 'mixed';
}
return 'meta';
}
private function resolveRoleDefinitionDiff(Tenant $tenant, int $baselinePolicyVersionId, int $currentPolicyVersionId): ?array
{
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return null;
}
return $this->roleDefinitionNormalizer->classifyDiff(
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
platform: is_string($currentVersion->platform ?? null)
? (string) $currentVersion->platform
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
);
}
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
{
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
'metadata_only' => Finding::SEVERITY_LOW,
default => Finding::SEVERITY_HIGH,
};
}
private function emptyRbacRoleDefinitionSummary(): array
{
return [
'total_compared' => 0,
'unchanged' => 0,
'modified' => 0,
'missing' => 0,
'unexpected' => 0,
];
}
private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array
{
$evidence = is_array($metaJsonb['evidence'] ?? null) ? $metaJsonb['evidence'] : $metaJsonb;
$fidelity = is_string($evidence['fidelity'] ?? null) ? strtolower(trim((string) $evidence['fidelity'])) : EvidenceProvenance::FidelityMeta;
$source = is_string($evidence['source'] ?? null) ? strtolower(trim((string) $evidence['source'])) : EvidenceProvenance::SourceInventory;
$observedAt = is_string($evidence['observed_at'] ?? null) ? trim((string) $evidence['observed_at']) : null;
$observedAtCarbon = null;
if ($observedAt !== null && $observedAt !== '') {
try {
$observedAtCarbon = \Carbon\CarbonImmutable::parse($observedAt);
} catch (\Throwable) {
$observedAtCarbon = null;
}
}
$observedOperationRunId = $evidence['observed_operation_run_id'] ?? null;
return EvidenceProvenance::build(
fidelity: EvidenceProvenance::isValidFidelity($fidelity) ? $fidelity : EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::isValidSource($source) ? $source : EvidenceProvenance::SourceInventory,
observedAt: $observedAtCarbon,
observedOperationRunId: is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null,
);
}
}