TenantAtlas/app/Services/AssignmentRestoreService.php
2025-12-23 11:20:35 +01:00

467 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class AssignmentRestoreService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
) {}
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, string> $groupMapping
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
*/
public function restore(
Tenant $tenant,
string $policyType,
string $policyId,
array $assignments,
array $groupMapping,
?RestoreRun $restoreRun = null,
?string $actorEmail = null,
?string $actorName = null,
): array {
$outcomes = [];
$summary = [
'success' => 0,
'failed' => 0,
'skipped' => 0,
];
if ($assignments === []) {
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$contract = $this->contracts->get($policyType);
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) {
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
$summary['failed']++;
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$context = [
'tenant' => $tenantIdentifier,
'policy_id' => $policyId,
'policy_type' => $policyType,
'restore_run_id' => $restoreRun?->id,
];
$preparedAssignments = [];
$preparedMeta = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$groupId = $assignment['target']['groupId'] ?? null;
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
if ($mappedGroupId === 'SKIP') {
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
$summary['skipped']++;
$this->logAssignmentOutcome(
status: 'skipped',
tenant: $tenant,
assignment: $assignment,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
]
);
continue;
}
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
$preparedAssignments[] = $assignmentToRestore;
$preparedMeta[] = [
'assignment' => $assignment,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
if ($preparedAssignments === []) {
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
if ($usesAssignAction) {
$this->graphLogger->logRequest('restore_assignments_assign', $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => ['assignments' => $preparedAssignments],
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
if ($assignResponse->successful()) {
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->successOutcome(
$meta['assignment'],
$meta['group_id'],
$meta['mapped_group_id']
);
$summary['success']++;
$this->logAssignmentOutcome(
status: 'created',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]
);
}
} else {
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
if ($preparedMeta === []) {
$outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse);
$summary['failed']++;
}
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->failureOutcome(
$meta['assignment'],
$reason,
$meta['group_id'],
$meta['mapped_group_id'],
$assignResponse
);
$summary['failed']++;
$this->logAssignmentOutcome(
status: 'failed',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $assignResponse->meta['error_message'] ?? null,
'graph_error_code' => $assignResponse->meta['error_code'] ?? null,
],
);
}
}
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$this->graphLogger->logRequest('restore_assignments_list', $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$response = $this->graphClient->request('GET', $listPath, $graphOptions);
$this->graphLogger->logResponse('restore_assignments_list', $response, $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existingAssignments = $response->data['value'] ?? [];
foreach ($existingAssignments as $existing) {
$assignmentId = $existing['id'] ?? null;
if (! is_string($assignmentId) || $assignmentId === '') {
continue;
}
$deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId);
if (! $deletePath) {
continue;
}
$this->graphLogger->logRequest('restore_assignments_delete', $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'assignment_id' => $assignmentId,
]);
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
$this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'assignment_id' => $assignmentId,
]);
if ($deleteResponse->failed()) {
Log::warning('Failed to delete existing assignment during restore', $context + [
'assignment_id' => $assignmentId,
'graph_error_message' => $deleteResponse->meta['error_message'] ?? null,
'graph_error_code' => $deleteResponse->meta['error_code'] ?? null,
]);
}
}
foreach ($preparedMeta as $index => $meta) {
$assignmentToRestore = $preparedAssignments[$index] ?? null;
if (! is_array($assignmentToRestore)) {
continue;
}
$this->graphLogger->logRequest('restore_assignments_create', $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]);
$createResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => $assignmentToRestore,
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]);
if ($createResponse->successful()) {
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
$summary['success']++;
$this->logAssignmentOutcome(
status: 'created',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]
);
} else {
$outcomes[] = $this->failureOutcome(
$meta['assignment'],
$createResponse->meta['error_message'] ?? 'Graph create failed',
$meta['group_id'],
$meta['mapped_group_id'],
$createResponse
);
$summary['failed']++;
$this->logAssignmentOutcome(
status: 'failed',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
],
);
}
usleep(100000);
}
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string
{
if (! is_string($template) || $template === '') {
return null;
}
$path = str_replace('{id}', urlencode($policyId), $template);
if ($assignmentId !== null) {
$path = str_replace('{assignmentId}', urlencode($assignmentId), $path);
}
return $path;
}
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
{
if (! $mappedGroupId) {
return $assignment;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true) && isset($target['groupId'])) {
$target['groupId'] = $mappedGroupId;
$assignment['target'] = $target;
}
return $assignment;
}
private function sanitizeAssignment(array $assignment): array
{
$assignment = Arr::except($assignment, ['id']);
$target = $assignment['target'] ?? [];
unset(
$target['group_display_name'],
$target['group_orphaned'],
$target['assignment_filter_name']
);
$assignment['target'] = $target;
return $assignment;
}
private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
{
return [
'status' => 'success',
'assignment' => $this->sanitizeAssignment($assignment),
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
{
return [
'status' => 'skipped',
'assignment' => $this->sanitizeAssignment($assignment),
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
private function failureOutcome(
?array $assignment,
string $reason,
?string $groupId = null,
?string $mappedGroupId = null,
?GraphResponse $response = null
): array {
return array_filter([
'status' => 'failed',
'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
'reason' => $reason,
'graph_error_message' => $response?->meta['error_message'] ?? null,
'graph_error_code' => $response?->meta['error_code'] ?? null,
'graph_request_id' => $response?->meta['request_id'] ?? null,
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
], static fn ($value) => $value !== null);
}
private function logAssignmentOutcome(
string $status,
Tenant $tenant,
array $assignment,
?RestoreRun $restoreRun,
?string $actorEmail,
?string $actorName,
array $metadata
): void {
$action = match ($status) {
'created' => 'restore.assignment.created',
'failed' => 'restore.assignment.failed',
default => 'restore.assignment.skipped',
};
$statusLabel = match ($status) {
'created' => 'success',
'failed' => 'failed',
default => 'warning',
};
$this->auditLogger->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => $metadata,
'assignment' => $this->sanitizeAssignment($assignment),
],
actorEmail: $actorEmail,
actorName: $actorName,
status: $statusLabel,
resourceType: 'restore_run',
resourceId: $restoreRun ? (string) $restoreRun->id : null
);
}
}