TenantAtlas/app/Services/Intune/FoundationMappingService.php
2025-12-26 23:28:35 +01:00

373 lines
11 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Support\Collection;
class FoundationMappingService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
private readonly FoundationSnapshotService $snapshotService,
) {}
/**
* @param Collection<int, BackupItem> $items
* @return array{entries: array<int, array<string, mixed>>, mapping: array<string, string>, failed: int, skipped: int}
*/
public function map(Tenant $tenant, Collection $items, bool $execute = false): array
{
$entries = [];
$mapping = [];
$failed = 0;
$skipped = 0;
if ($items->isEmpty()) {
return [
'entries' => $entries,
'mapping' => $mapping,
'failed' => $failed,
'skipped' => $skipped,
];
}
$itemsByType = $items->groupBy('policy_type');
foreach ($itemsByType as $foundationType => $typeItems) {
$foundationType = (string) $foundationType;
$existingOutcome = $this->snapshotService->fetchAll($tenant, $foundationType);
$existingFailures = $existingOutcome['failures'] ?? [];
if (! empty($existingFailures)) {
$reason = $existingFailures[0]['reason'] ?? 'Unable to list foundation resources.';
foreach ($typeItems as $item) {
$entries[] = $this->failureEntry($foundationType, $item, $reason);
$failed++;
}
continue;
}
$existing = $existingOutcome['items'] ?? [];
$existingByName = $this->indexExistingByName($existing);
$existingNames = array_keys($existingByName);
foreach ($typeItems as $item) {
$sourceId = $item->policy_identifier;
$sourceName = $item->resolvedDisplayName();
if ($sourceName === '') {
$entries[] = $this->skipEntry($foundationType, $sourceId, null, 'Missing display name.');
$skipped++;
continue;
}
$normalizedName = strtolower($sourceName);
$matches = $existingByName[$normalizedName] ?? [];
if (count($matches) === 1) {
$match = $matches[0];
$targetId = $match['source_id'] ?? null;
$targetName = $match['display_name'] ?? null;
if (is_string($targetId) && $targetId !== '') {
$mapping[$sourceId] = $targetId;
}
$entries[] = $this->decisionEntry(
$foundationType,
$sourceId,
$sourceName,
'mapped_existing',
$targetId,
$targetName,
null
);
continue;
}
$builtIn = $this->isBuiltInScopeTag($foundationType, $item);
if ($builtIn) {
$entries[] = $this->skipEntry(
$foundationType,
$sourceId,
$sourceName,
'Built-in scope tag cannot be created.'
);
$skipped++;
continue;
}
$decision = count($matches) > 1 ? 'created_copy' : 'created';
$targetName = $decision === 'created_copy'
? $this->resolveCopyName($sourceName, $existingNames)
: $sourceName;
if (! $execute) {
$entries[] = $this->decisionEntry(
$foundationType,
$sourceId,
$sourceName,
$decision,
null,
$targetName,
count($matches) > 1 ? 'Multiple matches found by name.' : null
);
continue;
}
$createOutcome = $this->createFoundation(
tenant: $tenant,
foundationType: $foundationType,
item: $item,
targetName: $targetName
);
if ($createOutcome['success'] ?? false) {
$targetId = $createOutcome['target_id'] ?? null;
$createdName = $createOutcome['target_name'] ?? $targetName;
if (is_string($targetId) && $targetId !== '') {
$mapping[$sourceId] = $targetId;
}
$entries[] = $this->decisionEntry(
$foundationType,
$sourceId,
$sourceName,
$decision,
$targetId,
$createdName,
null
);
continue;
}
$entries[] = $this->failureEntry(
$foundationType,
$item,
$createOutcome['reason'] ?? 'Failed to create foundation resource.'
);
$failed++;
}
}
return [
'entries' => $entries,
'mapping' => $mapping,
'failed' => $failed,
'skipped' => $skipped,
];
}
/**
* @param array<int, array{source_id:string,display_name:?string}> $existing
* @return array<string, array<int, array{source_id:string,display_name:?string}>>
*/
private function indexExistingByName(array $existing): array
{
$index = [];
foreach ($existing as $item) {
$name = $item['display_name'] ?? null;
if (! is_string($name) || $name === '') {
continue;
}
$index[strtolower($name)][] = [
'source_id' => $item['source_id'],
'display_name' => $name,
];
}
return $index;
}
private function resolveCopyName(string $baseName, array $existingNames): string
{
$suffix = ' (Copy)';
$candidate = $baseName.$suffix;
$normalized = array_map('strtolower', $existingNames);
if (! in_array(strtolower($candidate), $normalized, true)) {
return $candidate;
}
$counter = 2;
while (true) {
$candidate = sprintf('%s (Copy %d)', $baseName, $counter);
if (! in_array(strtolower($candidate), $normalized, true)) {
return $candidate;
}
$counter++;
}
}
private function isBuiltInScopeTag(string $foundationType, BackupItem $item): bool
{
if ($foundationType !== 'roleScopeTag') {
return false;
}
if ($item->policy_identifier === '0') {
return true;
}
$payload = is_array($item->payload) ? $item->payload : [];
return (bool) ($payload['isBuiltIn'] ?? false);
}
/**
* @return array{success: bool, target_id: ?string, target_name: ?string, reason: ?string}
*/
private function createFoundation(
Tenant $tenant,
string $foundationType,
BackupItem $item,
string $targetName
): array {
$resource = $this->contracts->resourcePath($foundationType);
if (! $resource) {
return [
'success' => false,
'target_id' => null,
'target_name' => null,
'reason' => 'Graph contract resource missing for foundation type.',
];
}
$contract = $this->contracts->get($foundationType);
$method = strtoupper((string) ($contract['create_method'] ?? 'POST'));
$payload = $this->contracts->sanitizeUpdatePayload($foundationType, is_array($item->payload) ? $item->payload : []);
$payload = $this->applyDisplayName($payload, $targetName);
if ($payload === []) {
return [
'success' => false,
'target_id' => null,
'target_name' => null,
'reason' => 'Foundation payload could not be sanitized.',
];
}
$response = $this->graphClient->request(
$method,
$resource,
['json' => $payload] + $tenant->graphOptions()
);
if ($response->failed()) {
return [
'success' => false,
'target_id' => null,
'target_name' => null,
'reason' => $response->meta['error_message'] ?? 'Graph create failed.',
];
}
$data = $response->data;
$targetId = is_array($data) ? ($data['id'] ?? null) : null;
$targetName = is_array($data) ? ($data['displayName'] ?? $data['name'] ?? $targetName) : $targetName;
return [
'success' => true,
'target_id' => is_string($targetId) ? $targetId : null,
'target_name' => is_string($targetName) ? $targetName : $targetName,
'reason' => null,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function applyDisplayName(array $payload, string $targetName): array
{
if (array_key_exists('displayName', $payload)) {
$payload['displayName'] = $targetName;
return $payload;
}
if (array_key_exists('name', $payload)) {
$payload['name'] = $targetName;
return $payload;
}
$payload['displayName'] = $targetName;
return $payload;
}
private function decisionEntry(
string $foundationType,
string $sourceId,
string $sourceName,
string $decision,
?string $targetId,
?string $targetName,
?string $reason
): array {
return array_filter([
'type' => $foundationType,
'sourceId' => $sourceId,
'sourceName' => $sourceName,
'decision' => $decision,
'targetId' => $targetId,
'targetName' => $targetName,
'reason' => $reason,
], static fn ($value) => $value !== null);
}
private function skipEntry(
string $foundationType,
string $sourceId,
?string $sourceName,
string $reason
): array {
return $this->decisionEntry(
$foundationType,
$sourceId,
$sourceName ?? $sourceId,
'skipped',
null,
null,
$reason
);
}
private function failureEntry(string $foundationType, BackupItem $item, string $reason): array
{
$sourceName = $item->resolvedDisplayName();
return $this->decisionEntry(
$foundationType,
$item->policy_identifier,
$sourceName,
'failed',
null,
null,
$reason
);
}
}