372 lines
15 KiB
PHP
372 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Models\TenantConfigurationSupportedScope;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use UnexpectedValueException;
|
|
|
|
final class SupportedScopeResolver
|
|
{
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function defaultDefinitions(): array
|
|
{
|
|
$tcmCoreTypes = [
|
|
'deviceAndAppManagementAssignmentFilter',
|
|
'deviceEnrollmentLimitRestriction',
|
|
'deviceEnrollmentPlatformRestriction',
|
|
'deviceEnrollmentStatusPageWindows10',
|
|
'appProtectionPolicyAndroid',
|
|
'appProtectionPolicyiOS',
|
|
];
|
|
|
|
return [
|
|
[
|
|
'scope_key' => 'intune_tcm_core',
|
|
'display_name' => 'Intune TCM core',
|
|
'description' => 'Initial TCM-backed Intune configuration denominator.',
|
|
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
|
|
'included_resource_types' => $tcmCoreTypes,
|
|
'allow_beta' => false,
|
|
'allow_graph_fallback' => false,
|
|
'customer_claims_allowed' => true,
|
|
'is_active' => true,
|
|
'metadata' => ['kernel' => 'coverage_v2', 'claim_surface' => 'future_activation'],
|
|
],
|
|
[
|
|
'scope_key' => 'intune_tcm_core_with_graph_fallback',
|
|
'display_name' => 'Intune TCM core with Graph fallback',
|
|
'description' => 'Initial TCM-backed denominator with explicitly allowed Graph v1 fallback resource types.',
|
|
'minimum_coverage_level' => CoverageLevel::Detected->value,
|
|
'included_resource_types' => [
|
|
...$tcmCoreTypes,
|
|
'notificationMessageTemplate',
|
|
],
|
|
'allow_beta' => false,
|
|
'allow_graph_fallback' => true,
|
|
'customer_claims_allowed' => true,
|
|
'is_active' => true,
|
|
'metadata' => ['kernel' => 'coverage_v2', 'claim_surface' => 'future_activation'],
|
|
],
|
|
...self::m365PlanningScopes(),
|
|
];
|
|
}
|
|
|
|
public function syncDefaults(): void
|
|
{
|
|
DB::table('tenant_configuration_supported_scopes')->upsert(
|
|
$this->rowsForUpsert(self::defaultDefinitions()),
|
|
['scope_key'],
|
|
[
|
|
'display_name',
|
|
'description',
|
|
'minimum_coverage_level',
|
|
'included_resource_types',
|
|
'allow_beta',
|
|
'allow_graph_fallback',
|
|
'customer_claims_allowed',
|
|
'is_active',
|
|
'metadata',
|
|
'updated_at',
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, TenantConfigurationSupportedScope>
|
|
*/
|
|
public function activeScopes(): Collection
|
|
{
|
|
return TenantConfigurationSupportedScope::query()
|
|
->active()
|
|
->orderBy('scope_key')
|
|
->get();
|
|
}
|
|
|
|
public function findActive(string $scopeKey): ?TenantConfigurationSupportedScope
|
|
{
|
|
return TenantConfigurationSupportedScope::query()
|
|
->active()
|
|
->where('scope_key', $scopeKey)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|TenantConfigurationSupportedScope $scope
|
|
* @param iterable<int, array<string, mixed>|TenantConfigurationResourceType>|null $resourceTypes
|
|
* @return array{scope_key: string, minimum_coverage_level: CoverageLevel, included_resource_types: list<string>, excluded_resource_types: list<string>, allow_beta: bool, allow_graph_fallback: bool, customer_claims_allowed: bool}
|
|
*/
|
|
public function resolveDefinition(array|TenantConfigurationSupportedScope $scope, ?iterable $resourceTypes = null): array
|
|
{
|
|
$scopeDefinition = $this->normalizeScope($scope);
|
|
$resourceTypes ??= ResourceTypeRegistry::defaultDefinitions();
|
|
$explicitDenominator = array_values(array_map('strval', $scopeDefinition['included_resource_types']));
|
|
$resourceTypesByCanonicalType = $this->indexResourceTypesByCanonicalType($resourceTypes);
|
|
$unknownResourceTypes = array_values(array_diff($explicitDenominator, array_keys($resourceTypesByCanonicalType)));
|
|
$included = [];
|
|
$excluded = [];
|
|
|
|
if ($unknownResourceTypes !== []) {
|
|
throw new UnexpectedValueException(sprintf(
|
|
'Supported scope [%s] references unknown resource type(s): %s.',
|
|
$scopeDefinition['scope_key'],
|
|
implode(', ', $unknownResourceTypes),
|
|
));
|
|
}
|
|
|
|
foreach ($explicitDenominator as $canonicalType) {
|
|
$definition = $resourceTypesByCanonicalType[$canonicalType];
|
|
$sourceClass = SourceClass::from($definition['source_class']);
|
|
|
|
if ($sourceClass->isBetaExperimental() && ! $scopeDefinition['allow_beta']) {
|
|
$excluded[] = $definition['canonical_type'];
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($sourceClass->isGraphFallback() && ! $scopeDefinition['allow_graph_fallback']) {
|
|
$excluded[] = $definition['canonical_type'];
|
|
|
|
continue;
|
|
}
|
|
|
|
$included[] = $definition['canonical_type'];
|
|
}
|
|
|
|
return [
|
|
'scope_key' => $scopeDefinition['scope_key'],
|
|
'minimum_coverage_level' => CoverageLevel::from($scopeDefinition['minimum_coverage_level']),
|
|
'included_resource_types' => array_values(array_unique($included)),
|
|
'excluded_resource_types' => array_values(array_unique($excluded)),
|
|
'allow_beta' => $scopeDefinition['allow_beta'],
|
|
'allow_graph_fallback' => $scopeDefinition['allow_graph_fallback'],
|
|
'customer_claims_allowed' => $scopeDefinition['customer_claims_allowed'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $observedCanonicalTypes
|
|
* @param array<string, mixed>|TenantConfigurationSupportedScope $scope
|
|
* @param iterable<int, array<string, mixed>|TenantConfigurationResourceType>|null $resourceTypes
|
|
*/
|
|
public function isScopeComplete(array $observedCanonicalTypes, array|TenantConfigurationSupportedScope $scope, ?iterable $resourceTypes = null): bool
|
|
{
|
|
$resolved = $this->resolveDefinition($scope, $resourceTypes);
|
|
|
|
return array_diff($resolved['included_resource_types'], $observedCanonicalTypes) === [];
|
|
}
|
|
|
|
public function meetsMinimum(CoverageLevel|string $actualLevel, array|TenantConfigurationSupportedScope $scope): bool
|
|
{
|
|
$actual = $actualLevel instanceof CoverageLevel ? $actualLevel : CoverageLevel::from($actualLevel);
|
|
$scopeDefinition = $this->normalizeScope($scope);
|
|
|
|
return $actual->meets(CoverageLevel::from($scopeDefinition['minimum_coverage_level']));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $definitions
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function rowsForUpsert(array $definitions): array
|
|
{
|
|
$now = now();
|
|
|
|
return array_map(static function (array $definition) use ($now): array {
|
|
$definition['included_resource_types'] = json_encode($definition['included_resource_types'], JSON_THROW_ON_ERROR);
|
|
$definition['metadata'] = json_encode($definition['metadata'] ?? null, JSON_THROW_ON_ERROR);
|
|
$definition['created_at'] = $now;
|
|
$definition['updated_at'] = $now;
|
|
|
|
return $definition;
|
|
}, $definitions);
|
|
}
|
|
|
|
/**
|
|
* @return array{scope_key: string, minimum_coverage_level: string, included_resource_types: list<string>, allow_beta: bool, allow_graph_fallback: bool, customer_claims_allowed: bool}
|
|
*/
|
|
private function normalizeScope(array|TenantConfigurationSupportedScope $scope): array
|
|
{
|
|
if ($scope instanceof TenantConfigurationSupportedScope) {
|
|
return [
|
|
'scope_key' => (string) $scope->scope_key,
|
|
'minimum_coverage_level' => $scope->minimum_coverage_level instanceof CoverageLevel
|
|
? $scope->minimum_coverage_level->value
|
|
: (string) $scope->minimum_coverage_level,
|
|
'included_resource_types' => array_values($scope->included_resource_types ?? []),
|
|
'allow_beta' => (bool) $scope->allow_beta,
|
|
'allow_graph_fallback' => (bool) $scope->allow_graph_fallback,
|
|
'customer_claims_allowed' => (bool) $scope->customer_claims_allowed,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'scope_key' => (string) $scope['scope_key'],
|
|
'minimum_coverage_level' => (string) $scope['minimum_coverage_level'],
|
|
'included_resource_types' => array_values($scope['included_resource_types'] ?? []),
|
|
'allow_beta' => (bool) ($scope['allow_beta'] ?? false),
|
|
'allow_graph_fallback' => (bool) ($scope['allow_graph_fallback'] ?? false),
|
|
'customer_claims_allowed' => (bool) ($scope['customer_claims_allowed'] ?? false),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param iterable<int, array<string, mixed>|TenantConfigurationResourceType> $resourceTypes
|
|
* @return array<string, array{canonical_type: string, source_class: string}>
|
|
*/
|
|
private function indexResourceTypesByCanonicalType(iterable $resourceTypes): array
|
|
{
|
|
$indexed = [];
|
|
|
|
foreach ($resourceTypes as $resourceType) {
|
|
$definition = $this->normalizeResourceType($resourceType);
|
|
$indexed[$definition['canonical_type']] = $definition;
|
|
}
|
|
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* @return array{canonical_type: string, source_class: string}
|
|
*/
|
|
private function normalizeResourceType(array|TenantConfigurationResourceType $resourceType): array
|
|
{
|
|
if ($resourceType instanceof TenantConfigurationResourceType) {
|
|
return [
|
|
'canonical_type' => (string) $resourceType->canonical_type,
|
|
'source_class' => $resourceType->source_class instanceof SourceClass
|
|
? $resourceType->source_class->value
|
|
: (string) $resourceType->source_class,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'canonical_type' => (string) $resourceType['canonical_type'],
|
|
'source_class' => (string) $resourceType['source_class'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private static function m365PlanningScopes(): array
|
|
{
|
|
$entra = [
|
|
'conditionalAccessPolicy',
|
|
'securityDefaults',
|
|
'application',
|
|
'servicePrincipal',
|
|
'roleDefinition',
|
|
'administrativeUnit',
|
|
];
|
|
$exchange = [
|
|
'transportRule',
|
|
'acceptedDomain',
|
|
'sharedMailbox',
|
|
'remoteDomain',
|
|
'mailboxPlan',
|
|
'organizationConfig',
|
|
];
|
|
$teams = [
|
|
'appPermissionPolicy',
|
|
'appSetupPolicy',
|
|
'meetingPolicy',
|
|
'messagingPolicy',
|
|
'teamsUpdateManagementPolicy',
|
|
'voiceRoute',
|
|
];
|
|
$securityCompliance = [
|
|
'labelPolicy',
|
|
'retentionCompliancePolicy',
|
|
'dlpCompliancePolicy',
|
|
'autoSensitivityLabelPolicy',
|
|
'protectionAlert',
|
|
'complianceTag',
|
|
];
|
|
$allRepresentative = [
|
|
...$entra,
|
|
...$exchange,
|
|
...$teams,
|
|
...$securityCompliance,
|
|
];
|
|
|
|
return [
|
|
self::m365Scope('m365_tcm_registry_detected', 'M365 TCM registry detected', $allRepresentative, [
|
|
'documentation_status' => 'combined_catalog',
|
|
'workload_documentation_status' => [
|
|
'entra' => 'documented_resource_catalog',
|
|
'exchange' => 'documented_resource_catalog',
|
|
'teams' => 'documented_resource_catalog',
|
|
'security_compliance' => 'combined_catalog',
|
|
'defender' => 'documented_overview_only',
|
|
'purview' => 'combined_catalog',
|
|
'tenantpilot' => 'internal',
|
|
'unknown' => 'unknown',
|
|
],
|
|
]),
|
|
self::m365Scope('entra_tcm_registry_detected', 'Entra TCM registry detected', $entra, [
|
|
'workload' => 'entra',
|
|
'documentation_status' => 'documented_resource_catalog',
|
|
]),
|
|
self::m365Scope('exchange_tcm_registry_detected', 'Exchange TCM registry detected', $exchange, [
|
|
'workload' => 'exchange',
|
|
'documentation_status' => 'documented_resource_catalog',
|
|
]),
|
|
self::m365Scope('teams_tcm_registry_detected', 'Teams TCM registry detected', $teams, [
|
|
'workload' => 'teams',
|
|
'documentation_status' => 'documented_resource_catalog',
|
|
]),
|
|
self::m365Scope('security_compliance_tcm_registry_detected', 'Security and Compliance TCM registry detected', $securityCompliance, [
|
|
'workload' => 'security_compliance',
|
|
'documentation_status' => 'combined_catalog',
|
|
]),
|
|
self::m365Scope('m365_tcm_generic_future', 'M365 TCM generic future', [], [
|
|
'documentation_status' => 'graph_only',
|
|
'future_only' => true,
|
|
'generic_capture_active' => false,
|
|
]),
|
|
self::m365Scope('m365_tcm_certified_none', 'M365 TCM certified none', [], [
|
|
'documentation_status' => 'internal',
|
|
'certified_scope_available' => false,
|
|
]),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $canonicalTypes
|
|
* @param array<string, mixed> $metadata
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function m365Scope(string $scopeKey, string $displayName, array $canonicalTypes, array $metadata): array
|
|
{
|
|
return [
|
|
'scope_key' => $scopeKey,
|
|
'display_name' => $displayName,
|
|
'description' => 'Inactive registry-only Microsoft 365 planning denominator.',
|
|
'minimum_coverage_level' => CoverageLevel::Detected->value,
|
|
'included_resource_types' => $canonicalTypes,
|
|
'allow_beta' => false,
|
|
'allow_graph_fallback' => false,
|
|
'customer_claims_allowed' => false,
|
|
'is_active' => false,
|
|
'metadata' => [
|
|
'kernel' => 'coverage_v2',
|
|
'registry_only' => true,
|
|
'planning_status' => 'detected_only',
|
|
'customer_claims_allowed' => false,
|
|
'is_full_catalog' => false,
|
|
'catalog_import_batch' => 'spec_419_seeded_representative_manifest',
|
|
...$metadata,
|
|
],
|
|
];
|
|
}
|
|
}
|