loadMissing('tenant'); $workspaceId = is_numeric($policy->tenant?->workspace_id ?? null) ? (int) $policy->tenant?->workspace_id : 0; $protectedSnapshot = $this->snapshotRedactor->protect( workspaceId: $workspaceId, payload: $payload, assignments: $assignments, scopeTags: $scopeTags, ); $version = null; $versionNumber = null; for ($attempt = 1; $attempt <= 3; $attempt++) { try { [$version, $versionNumber] = DB::transaction(function () use ($policy, $protectedSnapshot, $createdBy, $metadata, $capturePurpose, $operationRunId, $baselineProfileId): array { // Serialize version number allocation per policy. Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first(); $versionNumber = $this->nextVersionNumber($policy); $version = PolicyVersion::create([ 'tenant_id' => $policy->tenant_id, 'policy_id' => $policy->id, 'version_number' => $versionNumber, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'created_by' => $createdBy, 'captured_at' => CarbonImmutable::now(), 'snapshot' => $protectedSnapshot->snapshot, 'metadata' => $metadata, 'assignments' => $protectedSnapshot->assignments, 'scope_tags' => $protectedSnapshot->scopeTags, 'assignments_hash' => $protectedSnapshot->assignments !== null ? hash('sha256', json_encode($protectedSnapshot->assignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null, 'scope_tags_hash' => $protectedSnapshot->scopeTags !== null ? hash('sha256', json_encode($protectedSnapshot->scopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null, 'secret_fingerprints' => $protectedSnapshot->secretFingerprints, 'redaction_version' => $protectedSnapshot->redactionVersion, 'capture_purpose' => $capturePurpose->value, 'operation_run_id' => $operationRunId, 'baseline_profile_id' => $baselineProfileId, ]); return [$version, $versionNumber]; }, 3); break; } catch (QueryException $e) { if (! $this->isUniqueViolation($e) || $attempt === 3) { throw $e; } usleep(50_000 * $attempt); } } if (! $version instanceof PolicyVersion || ! is_int($versionNumber)) { throw new \RuntimeException('Failed to capture policy version.'); } $this->auditLogger->log( tenant: $policy->tenant, action: 'policy.versioned', context: [ 'metadata' => [ 'policy_id' => $policy->id, 'version_number' => $versionNumber, ], ], actorEmail: $createdBy, resourceType: 'policy', resourceId: (string) $policy->id ); return $version; } public function captureFoundationVersion( Policy $policy, array $payload, ?string $createdBy = null, array $metadata = [], PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, ?int $operationRunId = null, ?int $baselineProfileId = null, ): PolicyVersion { $policy->loadMissing('tenant'); $workspaceId = is_numeric($policy->tenant?->workspace_id ?? null) ? (int) $policy->tenant?->workspace_id : 0; $protectedSnapshot = $this->snapshotRedactor->protect( workspaceId: $workspaceId, payload: $payload, assignments: null, scopeTags: null, ); $snapshotHash = $this->snapshotContractHash( snapshot: $protectedSnapshot->snapshot, snapshotFingerprints: $protectedSnapshot->secretFingerprints['snapshot'], redactionVersion: $protectedSnapshot->redactionVersion, ); $existingVersion = PolicyVersion::query() ->where('policy_id', $policy->getKey()) ->where('capture_purpose', $capturePurpose->value) ->when( $capturePurpose !== PolicyVersionCapturePurpose::Backup && $baselineProfileId !== null, fn ($query) => $query->where('baseline_profile_id', $baselineProfileId), ) ->get() ->first(function (PolicyVersion $version) use ($snapshotHash): bool { return $this->snapshotContractHash( snapshot: is_array($version->snapshot) ? $version->snapshot : [], snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'), redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null, ) === $snapshotHash; }); if ($existingVersion instanceof PolicyVersion) { return $existingVersion; } return $this->captureVersion( policy: $policy, payload: $payload, createdBy: $createdBy, metadata: array_merge( ['capture_source' => 'foundation_capture'], $metadata, ), assignments: null, scopeTags: null, capturePurpose: $capturePurpose, operationRunId: $operationRunId, baselineProfileId: $baselineProfileId, ); } private function isUniqueViolation(QueryException $exception): bool { if ($exception instanceof UniqueConstraintViolationException) { return true; } $sqlState = $exception->getCode(); if (is_string($sqlState) && in_array($sqlState, ['23505', '23000'], true)) { return true; } $errorInfoState = $exception->errorInfo[0] ?? null; return is_string($errorInfoState) && in_array($errorInfoState, ['23505', '23000'], true); } public function captureFromGraph( Tenant $tenant, Policy $policy, ?string $createdBy = null, array $metadata = [], bool $includeAssignments = true, bool $includeScopeTags = true, PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, ?int $operationRunId = null, ?int $baselineProfileId = null, ): PolicyVersion { $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantIdentifier = (string) ($graphOptions['tenant'] ?? ''); $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { $reason = $snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'; throw new \RuntimeException($reason); } $payload = $snapshot['payload']; $snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; $assignments = null; $scopeTags = null; $assignmentMetadata = []; if ($includeAssignments) { try { $rawAssignments = $this->assignmentFetcher->fetch( $policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions, true, $payload['@odata.type'] ?? null, ); $assignmentMetadata['assignments_fetched'] = true; $assignmentMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; // Resolve groups $groupIds = collect($rawAssignments) ->pluck('target.groupId') ->filter() ->unique() ->values() ->toArray(); if (! empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); } $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) ->pluck('displayName', 'id') ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); } } catch (\Throwable $e) { $assignmentMetadata['assignments_fetch_failed'] = true; $assignmentMetadata['assignments_fetch_error'] = $e->getMessage(); $assignmentMetadata['assignments_fetch_error_code'] = $e instanceof GraphException ? ($e->status ?? null) : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); } } if ($includeScopeTags) { $scopeTagIds = $payload['roleScopeTagIds'] ?? ['0']; $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); } $metadata = array_merge( $snapshotMetadata, ['capture_source' => 'version_capture'], $metadata, $assignmentMetadata, ); if ($snapshotWarnings !== []) { $existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : []; $metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); } return $this->captureVersion( policy: $policy, payload: $payload, createdBy: $createdBy, metadata: $metadata, assignments: $assignments, scopeTags: $scopeTags, capturePurpose: $capturePurpose, operationRunId: $operationRunId, baselineProfileId: $baselineProfileId, ); } /** * @param array> $assignments * @param array $groups * @param array $filterNames * @return array> */ private function enrichAssignments(array $assignments, array $groups, array $filterNames): array { return array_map(function (array $assignment) use ($groups, $filterNames): array { $target = $assignment['target'] ?? []; $groupId = $target['groupId'] ?? null; if ($groupId && isset($groups[$groupId])) { $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); if ($filterId) { $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; if ($filterType) { $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; } if (isset($filterNames[$filterId])) { $target['assignment_filter_name'] = $filterNames[$filterId]; } } $assignment['target'] = $target; return $assignment; }, $assignments); } /** * @param array> $assignments * @return array */ 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) && $filterId !== '') { $filterIds[] = $filterId; } } return array_values(array_unique($filterIds)); } /** * @param array $scopeTagIds * @return array{ids:array,names:array} */ private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array { $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant); $names = []; foreach ($scopeTagIds as $id) { $scopeTag = collect($scopeTags)->firstWhere('id', $id); $names[] = $scopeTag['displayName'] ?? ($id === '0' ? 'Default' : "Unknown (ID: {$id})"); } return [ 'ids' => $scopeTagIds, 'names' => $names, ]; } /** * @param array $snapshot * @param array $snapshotFingerprints */ private function snapshotContractHash(array $snapshot, array $snapshotFingerprints, ?int $redactionVersion): string { return hash( 'sha256', json_encode( $this->normalizeHashValue([ 'snapshot' => $snapshot, 'secret_fingerprints' => $snapshotFingerprints, 'redaction_version' => $redactionVersion, ]), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ), ); } /** * @return array */ private function fingerprintBucket(PolicyVersion $version, string $bucket): array { $fingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : []; $bucketFingerprints = $fingerprints[$bucket] ?? []; return is_array($bucketFingerprints) ? $bucketFingerprints : []; } private function normalizeHashValue(mixed $value): mixed { if (! is_array($value)) { return $value; } if (array_is_list($value)) { return array_map(fn (mixed $item): mixed => $this->normalizeHashValue($item), $value); } ksort($value); foreach ($value as $key => $item) { $value[$key] = $this->normalizeHashValue($item); } return $value; } private function nextVersionNumber(Policy $policy): int { $current = PolicyVersion::query() ->where('policy_id', $policy->id) ->max('version_number'); return (int) ($current ?? 0) + 1; } }