454 lines
17 KiB
PHP
454 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Baselines\Support;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
|
use App\Support\Baselines\Compare\CompareState;
|
|
use App\Support\Baselines\Compare\CompareStrategy;
|
|
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
|
use App\Support\Baselines\Compare\CompareStrategyKey;
|
|
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
|
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
|
use App\Support\Baselines\OperatorActionCategory;
|
|
use App\Support\Baselines\ResolutionOutcome;
|
|
use App\Support\Baselines\ResolutionPath;
|
|
use App\Support\Baselines\SubjectClass;
|
|
use App\Support\Governance\GovernanceDomainKey;
|
|
use App\Support\Governance\GovernanceSubjectClass;
|
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
|
use App\Support\Governance\GovernanceSubjectType;
|
|
|
|
final class FakeCompareStrategy implements CompareStrategy
|
|
{
|
|
public function key(): CompareStrategyKey
|
|
{
|
|
return CompareStrategyKey::from('future_control');
|
|
}
|
|
|
|
public function capabilities(): array
|
|
{
|
|
return [
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
|
),
|
|
];
|
|
}
|
|
|
|
public function compare(
|
|
CompareOrchestrationContext $context,
|
|
Tenant $tenant,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
): array {
|
|
$subjectResults = [];
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
$currentItem = $currentItems[$key] ?? null;
|
|
|
|
if (! is_array($currentItem)) {
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: $baselineItem,
|
|
currentItem: null,
|
|
currentEvidence: null,
|
|
changeType: 'missing_policy',
|
|
severity: 'high',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
subjectKey: (string) $baselineItem['subject_key'],
|
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_current',
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: 'unusable',
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
|
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
|
|
|
|
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: $baselineItem,
|
|
currentItem: $currentItem,
|
|
currentEvidence: $currentEvidence,
|
|
changeType: 'different_version',
|
|
severity: 'medium',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$subjectResults[] = new CompareSubjectResult(
|
|
subjectIdentity: $this->identity(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
|
subjectKey: (string) $baselineItem['subject_key'],
|
|
),
|
|
projection: $this->projection(
|
|
policyType: (string) $baselineItem['policy_type'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
|
),
|
|
baselineAvailability: 'available',
|
|
currentStateAvailability: 'available',
|
|
compareState: CompareState::NoDrift,
|
|
trustLevel: 'trustworthy',
|
|
evidenceQuality: $currentEvidence->fidelity,
|
|
);
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (array_key_exists($key, $baselineItems)) {
|
|
continue;
|
|
}
|
|
|
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
|
|
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
|
$subjectResults[] = $this->gapResult(
|
|
policyType: (string) $currentItem['policy_type'],
|
|
subjectKey: (string) $currentItem['subject_key'],
|
|
externalSubjectId: (string) $currentItem['subject_external_id'],
|
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
|
|
compareState: CompareState::Incomplete,
|
|
reasonCode: 'missing_current',
|
|
baselineAvailability: 'missing',
|
|
currentStateAvailability: 'unknown',
|
|
trustLevel: 'unusable',
|
|
evidenceQuality: 'missing',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$subjectResults[] = $this->driftResult(
|
|
context: $context,
|
|
baselineItem: null,
|
|
currentItem: $currentItem,
|
|
currentEvidence: $currentEvidence,
|
|
changeType: 'unexpected_policy',
|
|
severity: 'low',
|
|
);
|
|
}
|
|
|
|
return [
|
|
'subject_results' => $subjectResults,
|
|
'diagnostics' => [
|
|
'strategy_family' => 'future_control',
|
|
'state_counts' => [
|
|
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $meta
|
|
*/
|
|
private function metaFingerprint(array $meta): string
|
|
{
|
|
unset($meta['display_name'], $meta['category'], $meta['platform']);
|
|
|
|
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sortRecursive(array $value): array
|
|
{
|
|
foreach ($value as $key => $nestedValue) {
|
|
if (! is_array($nestedValue)) {
|
|
continue;
|
|
}
|
|
|
|
$value[$key] = $this->sortRecursive($nestedValue);
|
|
}
|
|
|
|
ksort($value, SORT_STRING);
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function driftResult(
|
|
CompareOrchestrationContext $context,
|
|
?array $baselineItem,
|
|
?array $currentItem,
|
|
?ResolvedEvidence $currentEvidence,
|
|
string $changeType,
|
|
string $severity,
|
|
): CompareSubjectResult {
|
|
$source = $baselineItem ?? $currentItem ?? [];
|
|
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
|
|
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
|
|
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
|
|
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
|
|
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
|
|
$baselineProvenance = EvidenceProvenance::build(
|
|
fidelity: EvidenceProvenance::FidelityMeta,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: null,
|
|
);
|
|
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
|
|
fidelity: $fidelity,
|
|
source: EvidenceProvenance::SourceInventory,
|
|
observedAt: null,
|
|
observedOperationRunId: $context->inventorySyncRunId(),
|
|
);
|
|
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
|
projection: $this->projection($policyType, $operatorLabel),
|
|
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
|
|
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
|
|
compareState: CompareState::Drift,
|
|
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
|
|
evidenceQuality: $fidelity,
|
|
severityRecommendation: $severity,
|
|
findingCandidate: new CompareFindingCandidate(
|
|
changeType: $changeType,
|
|
severity: $severity,
|
|
fingerprintBasis: [
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'change_type' => $changeType,
|
|
],
|
|
evidencePayload: [
|
|
'change_type' => $changeType,
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'display_name' => $operatorLabel,
|
|
'summary' => ['kind' => 'control_snapshot'],
|
|
'baseline' => [
|
|
'hash' => $baselineItem['baseline_hash'] ?? null,
|
|
'provenance' => $baselineProvenance,
|
|
],
|
|
'current' => [
|
|
'hash' => $currentEvidence?->hash,
|
|
'provenance' => $currentProvenance,
|
|
],
|
|
'fidelity' => $fidelity,
|
|
'provenance' => [
|
|
'baseline_profile_id' => $context->baselineProfileId,
|
|
'baseline_snapshot_id' => $context->baselineSnapshotId,
|
|
'compare_operation_run_id' => $context->operationRunId,
|
|
'inventory_sync_run_id' => $context->inventorySyncRunId(),
|
|
],
|
|
],
|
|
),
|
|
diagnostics: [
|
|
'strategy_key' => $this->key()->value,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function gapResult(
|
|
string $policyType,
|
|
string $subjectKey,
|
|
string $externalSubjectId,
|
|
string $operatorLabel,
|
|
CompareState $compareState,
|
|
string $reasonCode,
|
|
string $baselineAvailability,
|
|
string $currentStateAvailability,
|
|
string $trustLevel,
|
|
string $evidenceQuality,
|
|
): CompareSubjectResult {
|
|
return new CompareSubjectResult(
|
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
|
projection: $this->projection($policyType, $operatorLabel),
|
|
baselineAvailability: $baselineAvailability,
|
|
currentStateAvailability: $currentStateAvailability,
|
|
compareState: $compareState,
|
|
trustLevel: $trustLevel,
|
|
evidenceQuality: $evidenceQuality,
|
|
diagnostics: [
|
|
'reason_code' => $reasonCode,
|
|
'gap_record' => [
|
|
'policy_type' => $policyType,
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => SubjectClass::Derived->value,
|
|
'resolution_path' => ResolutionPath::Derived->value,
|
|
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
|
|
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
|
|
'structural' => false,
|
|
'retryable' => $reasonCode === 'missing_current',
|
|
'reason_code' => $reasonCode,
|
|
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
|
|
{
|
|
return new CompareSubjectIdentity(
|
|
domainKey: GovernanceDomainKey::Entra->value,
|
|
subjectClass: GovernanceSubjectClass::Control->value,
|
|
subjectTypeKey: $policyType,
|
|
externalSubjectId: $externalSubjectId,
|
|
subjectKey: $subjectKey,
|
|
);
|
|
}
|
|
|
|
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
|
|
{
|
|
return new CompareSubjectProjection(
|
|
platformSubjectClass: 'control',
|
|
domainKey: GovernanceDomainKey::Entra->value,
|
|
subjectTypeKey: $policyType,
|
|
operatorLabel: $operatorLabel,
|
|
summaryKind: 'control_snapshot',
|
|
);
|
|
}
|
|
}
|
|
|
|
final class FailingCompareStrategy implements CompareStrategy
|
|
{
|
|
public function key(): CompareStrategyKey
|
|
{
|
|
return CompareStrategyKey::from('failing_control');
|
|
}
|
|
|
|
public function capabilities(): array
|
|
{
|
|
return [
|
|
new CompareStrategyCapability(
|
|
strategyKey: $this->key(),
|
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
|
),
|
|
];
|
|
}
|
|
|
|
public function compare(
|
|
CompareOrchestrationContext $context,
|
|
Tenant $tenant,
|
|
array $baselineItems,
|
|
array $currentItems,
|
|
array $resolvedCurrentEvidence,
|
|
array $severityMapping,
|
|
): array {
|
|
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
|
|
}
|
|
}
|
|
|
|
final class FakeGovernanceSubjectTaxonomyRegistry
|
|
{
|
|
private readonly GovernanceSubjectTaxonomyRegistry $inner;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->inner = new GovernanceSubjectTaxonomyRegistry;
|
|
}
|
|
|
|
public function all(): array
|
|
{
|
|
return array_values(array_merge($this->inner->all(), [
|
|
new GovernanceSubjectType(
|
|
domainKey: GovernanceDomainKey::Entra,
|
|
subjectClass: GovernanceSubjectClass::Control,
|
|
subjectTypeKey: 'conditionalAccessPolicy',
|
|
label: 'Conditional Access Policy',
|
|
description: 'Synthetic test-only future domain control',
|
|
captureSupported: true,
|
|
compareSupported: true,
|
|
inventorySupported: true,
|
|
active: true,
|
|
supportMode: 'supported',
|
|
legacyBucket: null,
|
|
),
|
|
]));
|
|
}
|
|
|
|
public function active(): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->all(),
|
|
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
|
|
));
|
|
}
|
|
|
|
public function activeLegacyBucketKeys(string $legacyBucket): array
|
|
{
|
|
$subjectTypes = array_filter(
|
|
$this->active(),
|
|
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
|
|
);
|
|
|
|
$keys = array_map(
|
|
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
|
|
$subjectTypes,
|
|
);
|
|
|
|
sort($keys, SORT_STRING);
|
|
|
|
return array_values(array_unique($keys));
|
|
}
|
|
|
|
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
|
|
{
|
|
foreach ($this->all() as $subjectType) {
|
|
if ($subjectType->domainKey->value !== trim($domainKey)) {
|
|
continue;
|
|
}
|
|
|
|
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
|
|
continue;
|
|
}
|
|
|
|
return $subjectType;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function isKnownDomain(string $domainKey): bool
|
|
{
|
|
return $this->inner->isKnownDomain($domainKey);
|
|
}
|
|
|
|
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
|
|
{
|
|
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
|
|
}
|
|
|
|
public function supportsFilters(string $domainKey, string $subjectClass): bool
|
|
{
|
|
return $this->inner->supportsFilters($domainKey, $subjectClass);
|
|
}
|
|
|
|
public function groupLabel(string $domainKey, string $subjectClass): string
|
|
{
|
|
return $this->inner->groupLabel($domainKey, $subjectClass);
|
|
}
|
|
} |