PolicyVersion, 'captured' => array] */ public function capture( Policy $policy, Tenant $tenant, bool $includeAssignments = false, bool $includeScopeTags = false, ?string $createdBy = null, array $metadata = [], PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, ?int $operationRunId = null, ?int $baselineProfileId = null, ): array { $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantIdentifier = (string) ($graphOptions['tenant'] ?? ''); // 1. Fetch policy snapshot $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { return [ 'failure' => $snapshot['failure'], ]; } $payload = $snapshot['payload']; $assignments = null; $scopeTags = null; $captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; if ($snapshotWarnings !== []) { $existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : []; $captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); } // 2. Fetch assignments if requested if ($includeAssignments) { try { $rawAssignments = $this->assignmentFetcher->fetch( $policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions, true, $payload['@odata.type'] ?? null, ); $captureMetadata['assignments_fetched'] = true; $captureMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; // Resolve groups for orphaned detection $groupIds = collect($rawAssignments) ->pluck('target.groupId') ->filter() ->unique() ->values() ->toArray(); if (! empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); $captureMetadata['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) { $captureMetadata['assignments_fetch_failed'] = true; $captureMetadata['assignments_fetch_error'] = $e->getMessage(); $captureMetadata['assignments_fetch_error_code'] = $e instanceof GraphException ? ($e->status ?? null) : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); Log::warning('Failed to fetch assignments during capture', [ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'error' => $e->getMessage(), ]); } } // 3. Fetch scope tags if requested if ($includeScopeTags) { $scopeTagIds = $payload['roleScopeTagIds'] ?? ['0']; $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); } $protectedSnapshot = $this->snapshotRedactor->protect( workspaceId: (int) $tenant->workspace_id, payload: $payload, assignments: $assignments, scopeTags: $scopeTags, ); $snapshotHash = $this->snapshotContractHash( snapshot: $protectedSnapshot->snapshot, snapshotFingerprints: $protectedSnapshot->secretFingerprints['snapshot'], redactionVersion: $protectedSnapshot->redactionVersion, ); $existingVersion = PolicyVersion::query() ->where('policy_id', $policy->id) ->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 ($protectedSnapshot, $snapshotHash) { return $this->matchesExistingVersionSnapshot( version: $version, snapshot: $protectedSnapshot->snapshot, snapshotHash: $snapshotHash, ); }); if ($existingVersion) { $updates = []; if ($includeAssignments && $existingVersion->assignments === null) { $updates['assignments'] = $protectedSnapshot->assignments; $updates['assignments_hash'] = $protectedSnapshot->assignments ? hash('sha256', json_encode($protectedSnapshot->assignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null; } if ($includeScopeTags && $existingVersion->scope_tags === null) { $updates['scope_tags'] = $protectedSnapshot->scopeTags; $updates['scope_tags_hash'] = $protectedSnapshot->scopeTags ? hash('sha256', json_encode($protectedSnapshot->scopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null; } if ($updates !== []) { $secretFingerprints = is_array($existingVersion->secret_fingerprints) ? $existingVersion->secret_fingerprints : ProtectedSnapshotResult::emptyFingerprints(); if (array_key_exists('assignments', $updates)) { $secretFingerprints['assignments'] = $protectedSnapshot->secretFingerprints['assignments']; } if (array_key_exists('scope_tags', $updates)) { $secretFingerprints['scope_tags'] = $protectedSnapshot->secretFingerprints['scope_tags']; } $updates['secret_fingerprints'] = $secretFingerprints; } if (! empty($updates)) { $existingVersion->update($updates); Log::info('Backfilled existing PolicyVersion with capture data', [ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_id' => $existingVersion->id, 'version_number' => $existingVersion->version_number, 'assignments_backfilled' => array_key_exists('assignments', $updates), 'scope_tags_backfilled' => array_key_exists('scope_tags', $updates), ]); return [ 'version' => $existingVersion->fresh(), 'captured' => [ 'payload' => $protectedSnapshot->snapshot, 'assignments' => $protectedSnapshot->assignments, 'scope_tags' => $protectedSnapshot->scopeTags, 'metadata' => $captureMetadata, ], ]; } Log::info('Reusing existing PolicyVersion', [ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_id' => $existingVersion->id, 'version_number' => $existingVersion->version_number, ]); return [ 'version' => $existingVersion, 'captured' => [ 'payload' => $protectedSnapshot->snapshot, 'assignments' => $protectedSnapshot->assignments, 'scope_tags' => $protectedSnapshot->scopeTags, 'metadata' => $captureMetadata, ], ]; } // 5. Create new PolicyVersion with all captured data $metadata = array_merge( ['capture_source' => 'orchestrated_capture'], $metadata, $captureMetadata, ); $version = $this->versionService->captureVersion( policy: $policy, payload: $payload, createdBy: $createdBy, metadata: $metadata, assignments: $assignments, scopeTags: $scopeTags, capturePurpose: $capturePurpose, operationRunId: $operationRunId, baselineProfileId: $baselineProfileId, ); Log::info('Policy captured via orchestrator', [ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_id' => $version->id, 'version_number' => $version->version_number, 'has_assignments' => ! is_null($assignments), 'has_scope_tags' => ! is_null($scopeTags), ]); return [ 'version' => $version, 'captured' => [ 'payload' => $protectedSnapshot->snapshot, 'assignments' => $protectedSnapshot->assignments, 'scope_tags' => $protectedSnapshot->scopeTags, 'metadata' => $captureMetadata, ], ]; } /** * Ensure existing PolicyVersion has assignments if missing. */ public function ensureVersionHasAssignments( PolicyVersion $version, Tenant $tenant, Policy $policy, bool $includeAssignments = false, bool $includeScopeTags = false ): PolicyVersion { $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantIdentifier = (string) ($graphOptions['tenant'] ?? ''); if ($version->assignments !== null && $version->scope_tags !== null) { Log::debug('Version already has assignments, skipping', [ 'version_id' => $version->id, ]); return $version; } // Only fetch if requested if (! $includeAssignments && ! $includeScopeTags) { return $version; } $assignments = null; $scopeTags = null; $metadata = $version->metadata ?? []; if ($includeAssignments && $version->assignments === null) { try { $rawAssignments = $this->assignmentFetcher->fetch( $policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions, true, is_array($version->snapshot) ? ($version->snapshot['@odata.type'] ?? null) : null, ); $metadata['assignments_fetched'] = true; $metadata['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); $metadata['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) { $metadata['assignments_fetch_failed'] = true; $metadata['assignments_fetch_error'] = $e->getMessage(); $metadata['assignments_fetch_error_code'] = $e instanceof GraphException ? ($e->status ?? null) : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); Log::warning('Failed to backfill assignments for version', [ 'version_id' => $version->id, 'error' => $e->getMessage(), ]); } } // Fetch scope tags if ($includeScopeTags && $version->scope_tags === null) { $scopeTagIds = $version->snapshot['roleScopeTagIds'] ?? ['0']; $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); } $updates = []; if ($includeAssignments && $version->assignments === null) { $redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments); $updates['assignments'] = $redactedAssignments; $updates['assignments_hash'] = $redactedAssignments ? hash('sha256', json_encode($redactedAssignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null; } if ($includeScopeTags && $version->scope_tags === null) { $redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags); $updates['scope_tags'] = $redactedScopeTags; $updates['scope_tags_hash'] = $redactedScopeTags ? hash('sha256', json_encode($redactedScopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null; } if (! empty($updates)) { $updates['metadata'] = $metadata; $version->update($updates); } Log::info('Version backfilled with capture data', [ 'version_id' => $version->id, 'has_assignments' => ! is_null($assignments), 'has_scope_tags' => ! is_null($scopeTags), ]); return $version->refresh(); } /** * @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 matchesExistingVersionSnapshot(PolicyVersion $version, array $snapshot, string $snapshotHash): bool { $currentHash = $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, ); if ($currentHash === $snapshotHash) { return true; } $hasLegacySnapshotContract = $this->fingerprintBucket($version, 'snapshot') === [] && ! is_numeric($version->redaction_version); if (! $hasLegacySnapshotContract) { return false; } return $this->normalizeHashValue(is_array($version->snapshot) ? $version->snapshot : []) === $this->normalizeHashValue($snapshot); } 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; } }