TenantAtlas/apps/platform/app/Support/Baselines/BaselineScope.php

684 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException;
/**
* Value object for baseline scope resolution.
*
* Canonical storage uses versioned Governance Scope V2 entries.
* Presentation compatibility for the current Intune-first UI still projects
* back to legacy policy and foundation buckets.
*/
final class BaselineScope
{
/**
* @param list<string> $policyTypes
* @param list<string> $foundationTypes
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @param list<string> $legacyKeysPresent
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
public readonly array $entries = [],
public readonly int $version = 2,
public readonly string $sourceShape = 'canonical_v2',
public readonly bool $normalizedOnRead = false,
public readonly array $legacyKeysPresent = [],
public readonly bool $saveForwardRequired = false,
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb, bool $allowEmptyLegacyAsNoOverride = false): self
{
if ($scopeJsonb === null) {
return self::fromLegacyPayload(
policyTypes: [],
foundationTypes: [],
legacyKeysPresent: [],
normalizedOnRead: true,
saveForwardRequired: true,
);
}
if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) {
return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride);
}
$hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb);
$hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb);
if ($hasLegacyKeys && $hasCanonicalKeys) {
throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.');
}
if ($hasCanonicalKeys) {
return self::fromCanonicalPayload($scopeJsonb);
}
if (! $hasLegacyKeys) {
throw new InvalidArgumentException('Baseline scope payload must contain either legacy buckets or canonical V2 keys.');
}
$legacyKeysPresent = array_values(array_filter([
array_key_exists('policy_types', $scopeJsonb) ? 'policy_types' : null,
array_key_exists('foundation_types', $scopeJsonb) ? 'foundation_types' : null,
]));
$policyTypes = self::stringList($scopeJsonb['policy_types'] ?? []);
$foundationTypes = self::stringList($scopeJsonb['foundation_types'] ?? []);
if ($allowEmptyLegacyAsNoOverride && $policyTypes === [] && $foundationTypes === []) {
return new self(
policyTypes: [],
foundationTypes: [],
entries: [],
sourceShape: 'legacy',
normalizedOnRead: false,
legacyKeysPresent: $legacyKeysPresent,
saveForwardRequired: false,
);
}
return self::fromLegacyPayload(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
legacyKeysPresent: $legacyKeysPresent,
normalizedOnRead: true,
saveForwardRequired: true,
);
}
/**
* Normalize the effective scope by intersecting profile scope with an optional override.
*
* Override can only narrow the profile scope (subset enforcement).
* Empty override means "no override".
*/
public static function effective(self $profileScope, ?self $overrideScope): self
{
$profileScope = $profileScope->expandDefaults();
if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope;
}
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$entries = [];
foreach ($profileScope->entries as $entry) {
$subjectTypeKeys = $entry['subject_type_keys'];
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value
&& $overridePolicyTypes !== []) {
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes));
}
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value
&& $overrideFoundationTypes !== []) {
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overrideFoundationTypes));
}
$subjectTypeKeys = self::uniqueSorted($subjectTypeKeys);
if ($subjectTypeKeys === []) {
continue;
}
$entries[] = [
'domain_key' => $entry['domain_key'],
'subject_class' => $entry['subject_class'],
'subject_type_keys' => $subjectTypeKeys,
'filters' => $entry['filters'],
];
}
return self::fromCanonicalPayload([
'version' => 2,
'entries' => $entries,
]);
}
/**
* An empty scope means "no override" (for override_scope semantics).
*/
public function isEmpty(): bool
{
return $this->entries === [] && $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
* Apply Spec 116 defaults and filter to supported types.
*/
public function expandDefaults(): self
{
if ($this->entries !== []) {
return $this;
}
return self::fromLegacyPayload(
policyTypes: $this->policyTypes,
foundationTypes: $this->foundationTypes,
legacyKeysPresent: $this->legacyKeysPresent,
normalizedOnRead: $this->normalizedOnRead,
saveForwardRequired: $this->saveForwardRequired,
);
}
/**
* @return list<string>
*/
public function allTypes(): array
{
$expanded = $this->expandDefaults();
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
));
}
/**
* @return list<string>
*/
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return $guardResult['allowed_types'];
}
/**
* @return array<string, mixed>
*/
public function toJsonb(): array
{
$supportedPolicyTypes = self::supportedPolicyTypes();
$policyTypes = $this->policyTypes;
if ($policyTypes === $supportedPolicyTypes) {
$policyTypes = [];
}
return [
'policy_types' => $policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
*/
public function toStoredJsonb(): array
{
return [
'version' => 2,
'entries' => $this->entries,
];
}
/**
* @return array{source_shape: string, normalized_on_read: bool, legacy_keys_present: list<string>, save_forward_required: bool}
*/
public function normalizationLineage(): array
{
return [
'source_shape' => $this->sourceShape,
'normalized_on_read' => $this->normalizedOnRead,
'legacy_keys_present' => $this->legacyKeysPresent,
'save_forward_required' => $this->saveForwardRequired,
];
}
/**
* @return list<array{domain_key: string, subject_class: string, group_label: string, selected_subject_types: list<string>, capture_supported_count: int, compare_supported_count: int, inactive_count: int}>
*/
public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = null): array
{
$registry ??= app(GovernanceSubjectTaxonomyRegistry::class);
$groups = [];
foreach ($this->entries as $entry) {
$selectedSubjectTypes = [];
$captureSupportedCount = 0;
$compareSupportedCount = 0;
$inactiveCount = 0;
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
$subjectType = $registry->find($entry['domain_key'], $subjectTypeKey);
$selectedSubjectTypes[] = $subjectType?->label ?? $subjectTypeKey;
if ($subjectType?->captureSupported) {
$captureSupportedCount++;
}
if ($subjectType?->compareSupported) {
$compareSupportedCount++;
}
if ($subjectType !== null && ! $subjectType->active) {
$inactiveCount++;
}
}
sort($selectedSubjectTypes, SORT_STRING);
$groups[] = [
'domain_key' => $entry['domain_key'],
'subject_class' => $entry['subject_class'],
'group_label' => $registry->groupLabel($entry['domain_key'], $entry['subject_class']),
'selected_subject_types' => $selectedSubjectTypes,
'capture_supported_count' => $captureSupportedCount,
'compare_supported_count' => $compareSupportedCount,
'inactive_count' => $inactiveCount,
];
}
return $groups;
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array<string, mixed>
*/
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
$context = [
'canonical_scope' => $expanded->toStoredJsonb(),
'legacy_projection' => $expanded->toJsonb(),
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
if (! is_string($operation) || $operation === '') {
return $context;
}
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($allTypes, $operation);
return array_merge($context, [
'truthful_types' => $guardResult['allowed_types'],
'limited_types' => $guardResult['limited_types'],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
'allowed_type_keys' => $guardResult['allowed_types'],
'limited_type_keys' => $guardResult['limited_types'],
'unsupported_type_keys' => $guardResult['unsupported_types'],
'capabilities_by_type' => $guardResult['capabilities'],
]);
}
/**
* @return array{ok: bool, unsupported_types: list<string>, invalid_support_types: list<string>}
*/
public function operationEligibility(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return [
'ok' => $guardResult['unsupported_types'] === [] && $guardResult['invalid_support_types'] === [],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('policy_types');
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types');
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
private static function uniqueSorted(array $types): array
{
$types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')));
sort($types, SORT_STRING);
return $types;
}
/**
* @param list<string> $policyTypes
* @param list<string> $foundationTypes
* @param list<string> $legacyKeysPresent
*/
private static function fromLegacyPayload(
array $policyTypes,
array $foundationTypes,
array $legacyKeysPresent,
bool $normalizedOnRead,
bool $saveForwardRequired,
): self {
$policyTypes = $policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($policyTypes);
$foundationTypes = self::normalizeFoundationTypes($foundationTypes);
$entries = [];
if ($policyTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::Intune->value,
'subject_class' => GovernanceSubjectClass::Policy->value,
'subject_type_keys' => $policyTypes,
'filters' => [],
];
}
if ($foundationTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => $foundationTypes,
'filters' => [],
];
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
entries: $entries,
sourceShape: 'legacy',
normalizedOnRead: $normalizedOnRead,
legacyKeysPresent: $legacyKeysPresent,
saveForwardRequired: $saveForwardRequired,
);
}
/**
* @param array<string, mixed> $scopeJsonb
*/
private static function fromCanonicalPayload(array $scopeJsonb): self
{
if (($scopeJsonb['version'] ?? null) !== 2) {
throw new InvalidArgumentException('Baseline scope version must equal 2.');
}
$entries = $scopeJsonb['entries'] ?? null;
if (! is_array($entries) || $entries === []) {
throw new InvalidArgumentException('Baseline scope V2 entries must be a non-empty array.');
}
$normalizedEntries = self::normalizeEntries($entries);
[$policyTypes, $foundationTypes] = self::legacyProjectionFromEntries($normalizedEntries);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
entries: $normalizedEntries,
sourceShape: 'canonical_v2',
normalizedOnRead: false,
legacyKeysPresent: [],
saveForwardRequired: false,
);
}
/**
* @param list<mixed> $entries
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
*/
private static function normalizeEntries(array $entries): array
{
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
$normalizedEntries = [];
$subjectFilters = [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
throw new InvalidArgumentException('Each canonical baseline scope entry must be an array.');
}
$domainKey = trim((string) ($entry['domain_key'] ?? ''));
$subjectClass = trim((string) ($entry['subject_class'] ?? ''));
if (! $registry->isKnownDomain($domainKey)) {
throw new InvalidArgumentException('Unknown governance domain ['.$domainKey.'].');
}
if (! $registry->allowsSubjectClass($domainKey, $subjectClass)) {
throw new InvalidArgumentException('Subject class ['.$subjectClass.'] is not valid for domain ['.$domainKey.'].');
}
$subjectTypeKeys = self::stringList($entry['subject_type_keys'] ?? null);
if ($subjectTypeKeys === []) {
throw new InvalidArgumentException('Canonical baseline scope entries must include at least one subject type key.');
}
$filters = $entry['filters'] ?? [];
if (! is_array($filters)) {
throw new InvalidArgumentException('Baseline scope entry filters must be an object-shaped array.');
}
$filters = self::normalizeFilters($filters);
if ($filters !== [] && ! $registry->supportsFilters($domainKey, $subjectClass)) {
throw new InvalidArgumentException('Filters are not supported for the current governance domain and subject class.');
}
$subjectTypeKeys = array_map(
static fn (string $subjectTypeKey): string => trim($subjectTypeKey),
self::uniqueSorted($subjectTypeKeys),
);
foreach ($subjectTypeKeys as $subjectTypeKey) {
$subjectType = $registry->find($domainKey, $subjectTypeKey);
if ($subjectType === null) {
throw new InvalidArgumentException('Unknown subject type ['.$subjectTypeKey.'] for domain ['.$domainKey.'].');
}
if ($subjectType->subjectClass->value !== $subjectClass) {
throw new InvalidArgumentException('Subject type ['.$subjectTypeKey.'] does not belong to subject class ['.$subjectClass.'].');
}
if (! $subjectType->active) {
throw new InvalidArgumentException('Inactive subject type ['.$subjectTypeKey.'] cannot be selected.');
}
}
$filtersHash = self::filtersHash($filters);
foreach ($subjectTypeKeys as $subjectTypeKey) {
$subjectKey = implode('|', [$domainKey, $subjectClass, $subjectTypeKey]);
$existingFiltersHash = $subjectFilters[$subjectKey] ?? null;
if ($existingFiltersHash !== null && $existingFiltersHash !== $filtersHash) {
throw new InvalidArgumentException('Ambiguous baseline scope filters were provided for ['.$subjectTypeKey.'].');
}
$subjectFilters[$subjectKey] = $filtersHash;
}
$entryKey = implode('|', [$domainKey, $subjectClass, $filtersHash]);
if (! array_key_exists($entryKey, $normalizedEntries)) {
$normalizedEntries[$entryKey] = [
'domain_key' => $domainKey,
'subject_class' => $subjectClass,
'subject_type_keys' => [],
'filters' => $filters,
];
}
$normalizedEntries[$entryKey]['subject_type_keys'] = self::uniqueSorted(array_merge(
$normalizedEntries[$entryKey]['subject_type_keys'],
$subjectTypeKeys,
));
}
$normalizedEntries = array_values($normalizedEntries);
usort($normalizedEntries, static function (array $left, array $right): int {
$leftKey = implode('|', [
$left['domain_key'],
$left['subject_class'],
self::filtersHash($left['filters']),
implode(',', $left['subject_type_keys']),
]);
$rightKey = implode('|', [
$right['domain_key'],
$right['subject_class'],
self::filtersHash($right['filters']),
implode(',', $right['subject_type_keys']),
]);
return $leftKey <=> $rightKey;
});
return $normalizedEntries;
}
/**
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @return array{0: list<string>, 1: list<string>}
*/
private static function legacyProjectionFromEntries(array $entries): array
{
$policyTypes = [];
$foundationTypes = [];
foreach ($entries as $entry) {
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value) {
$policyTypes = array_merge($policyTypes, $entry['subject_type_keys']);
}
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value) {
$foundationTypes = array_merge($foundationTypes, $entry['subject_type_keys']);
}
}
return [
self::uniqueSorted($policyTypes),
self::uniqueSorted($foundationTypes),
];
}
/**
* @param mixed $values
* @return list<string>
*/
private static function stringList(mixed $values): array
{
if (! is_array($values)) {
return [];
}
return array_values(array_filter($values, 'is_string'));
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private static function normalizeFilters(array $filters): array
{
ksort($filters);
foreach ($filters as $key => $value) {
if (is_array($value)) {
$filters[$key] = self::normalizeFilterArray($value);
}
}
return $filters;
}
/**
* @param array<int|string, mixed> $values
* @return array<int|string, mixed>
*/
private static function normalizeFilterArray(array $values): array
{
foreach ($values as $key => $value) {
if (is_array($value)) {
$values[$key] = self::normalizeFilterArray($value);
}
}
if (array_is_list($values)) {
sort($values);
return array_values($values);
}
ksort($values);
return $values;
}
/**
* @param array<string, mixed> $filters
*/
private static function filtersHash(array $filters): string
{
return json_encode($filters, JSON_THROW_ON_ERROR);
}
}