Implements spec 094 (assignment fetch/restore observability hardening): - Adds OperationRun tracking for assignment fetch (during backup) and assignment restore (during restore execution) - Normalizes failure codes/reason_code and sanitizes failure messages - Ensures exactly one audit log entry per assignment restore execution - Enforces correct guard/membership vs capability semantics on affected admin surfaces - Switches assignment Graph services to depend on GraphClientInterface Also includes Postgres-only FK defense-in-depth check and a discoverable `composer test:pgsql` runner (scoped to the FK constraint test). Tests: - `vendor/bin/sail artisan test --compact` (passed) - `vendor/bin/sail composer test:pgsql` (passed) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #113
497 lines
17 KiB
PHP
497 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\RestoreRun;
|
|
use App\Models\Tenant;
|
|
use App\Services\Graph\AssignmentFilterResolver;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphContractRegistry;
|
|
use App\Services\Graph\GraphLogger;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
|
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 AssignmentFilterResolver $assignmentFilterResolver,
|
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $assignments
|
|
* @param array<string, string> $groupMapping
|
|
* @param array<string, array<string, string>> $foundationMapping
|
|
* @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,
|
|
array $foundationMapping = [],
|
|
?RestoreRun $restoreRun = null,
|
|
?string $actorEmail = null,
|
|
?string $actorName = null,
|
|
?string $policyOdataType = 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'));
|
|
|
|
if ($policyType === 'appProtectionPolicy') {
|
|
$derivedAssignPath = $this->resolveAppProtectionAssignmentsCreatePath($policyId, $policyOdataType);
|
|
|
|
if ($derivedAssignPath !== null) {
|
|
$createPath = $derivedAssignPath;
|
|
}
|
|
}
|
|
|
|
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
|
|
$assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments';
|
|
|
|
if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') {
|
|
$assignmentsPayloadKey = 'assignments';
|
|
}
|
|
$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 = $this->graphOptionsResolver->resolveForTenant($tenant);
|
|
$tenantIdentifier = (string) ($graphOptions['tenant'] ?? '');
|
|
|
|
$context = [
|
|
'tenant' => $tenantIdentifier,
|
|
'policy_id' => $policyId,
|
|
'policy_type' => $policyType,
|
|
'restore_run_id' => $restoreRun?->id,
|
|
];
|
|
|
|
$preparedAssignments = [];
|
|
$preparedMeta = [];
|
|
|
|
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? [];
|
|
|
|
if ($assignmentFilterMapping === []) {
|
|
$filterIds = $this->extractAssignmentFilterIds($assignments);
|
|
|
|
if ($filterIds !== []) {
|
|
$resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
|
|
|
foreach ($resolvedFilters as $filter) {
|
|
$filterId = $filter['id'] ?? null;
|
|
|
|
if (is_string($filterId) && $filterId !== '') {
|
|
$assignmentFilterMapping[$filterId] = $filterId;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment['target'] ?? [];
|
|
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
|
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
|
$filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target';
|
|
|
|
if (! is_string($filterId) && ! is_int($filterId)) {
|
|
$filterId = null;
|
|
}
|
|
|
|
if (is_string($filterId) && $filterId === '') {
|
|
$filterId = null;
|
|
}
|
|
|
|
if ($filterId !== null) {
|
|
if ($assignmentFilterMapping === []) {
|
|
$outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.');
|
|
$summary['skipped']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$mappedFilterId = $assignmentFilterMapping[$filterId] ?? null;
|
|
|
|
if ($mappedFilterId === null) {
|
|
$outcomes[] = $this->skipOutcome(
|
|
$assignment,
|
|
null,
|
|
null,
|
|
'Assignment filter mapping missing for filter ID.'
|
|
);
|
|
$summary['skipped']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($filterLocation === 'root') {
|
|
$assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
|
} else {
|
|
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
|
$assignment['target'] = $target;
|
|
}
|
|
}
|
|
|
|
$groupId = $assignment['target']['groupId'] ?? null;
|
|
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
|
|
|
|
if ($mappedGroupId === 'SKIP') {
|
|
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
|
$summary['skipped']++;
|
|
|
|
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' => [$assignmentsPayloadKey => $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']++;
|
|
}
|
|
} 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']++;
|
|
}
|
|
}
|
|
|
|
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']++;
|
|
} else {
|
|
$outcomes[] = $this->failureOutcome(
|
|
$meta['assignment'],
|
|
$createResponse->meta['error_message'] ?? 'Graph create failed',
|
|
$meta['group_id'],
|
|
$meta['mapped_group_id'],
|
|
$createResponse
|
|
);
|
|
$summary['failed']++;
|
|
}
|
|
|
|
usleep(100000);
|
|
}
|
|
|
|
return [
|
|
'outcomes' => $outcomes,
|
|
'summary' => $summary,
|
|
];
|
|
}
|
|
|
|
private function resolveAppProtectionAssignmentsCreatePath(string $policyId, ?string $odataType): ?string
|
|
{
|
|
$entitySet = $this->resolveAppProtectionEntitySet($odataType);
|
|
|
|
if ($entitySet === null) {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolvePath("/deviceAppManagement/{$entitySet}/{id}/assign", $policyId);
|
|
}
|
|
|
|
private function resolveAppProtectionEntitySet(?string $odataType): ?string
|
|
{
|
|
if (! is_string($odataType) || $odataType === '') {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower($odataType)) {
|
|
'#microsoft.graph.androidmanagedappprotection' => 'androidManagedAppProtections',
|
|
'#microsoft.graph.iosmanagedappprotection' => 'iosManagedAppProtections',
|
|
'#microsoft.graph.windowsinformationprotectionpolicy' => 'windowsInformationProtectionPolicies',
|
|
'#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'mdmWindowsInformationProtectionPolicies',
|
|
'#microsoft.graph.targetedmanagedappprotection' => 'targetedManagedAppProtections',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $assignments
|
|
* @return array<int, string>
|
|
*/
|
|
private function extractAssignmentFilterIds(array $assignments): array
|
|
{
|
|
$filterIds = [];
|
|
|
|
foreach ($assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
|
|
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
|
|
|
|
if (is_string($filterId) || is_int($filterId)) {
|
|
$filterId = (string) $filterId;
|
|
|
|
if ($filterId !== '') {
|
|
$filterIds[] = $filterId;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($filterIds));
|
|
}
|
|
|
|
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,
|
|
?string $reason = null
|
|
): array {
|
|
return [
|
|
'status' => 'skipped',
|
|
'assignment' => $this->sanitizeAssignment($assignment),
|
|
'group_id' => $groupId,
|
|
'mapped_group_id' => $mappedGroupId,
|
|
'reason' => $reason,
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|