PolicyVersion, 'captured' => array] */ public function capture( Policy $policy, Tenant $tenant, bool $includeAssignments = false, bool $includeScopeTags = false, ?string $createdBy = null, array $metadata = [] ): array { $graphOptions = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); // 1. Fetch policy snapshot $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); } $payload = $snapshot['payload']; $assignments = null; $scopeTags = null; $captureMetadata = []; // 2. Fetch assignments if requested if ($includeAssignments) { try { $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); 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 = collect($rawAssignments) ->pluck('target.deviceAndAppManagementAssignmentFilterId') ->filter() ->unique() ->values() ->all(); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) ->pluck('displayName', 'id') ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); $captureMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $captureMetadata['assignments_fetch_failed'] = true; $captureMetadata['assignments_fetch_error'] = $e->getMessage(); 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); } // 4. Check if PolicyVersion with same snapshot already exists $snapshotHash = hash('sha256', json_encode($payload)); // Find existing version by comparing snapshot content (database-agnostic) $existingVersion = PolicyVersion::where('policy_id', $policy->id) ->get() ->first(function ($version) use ($snapshotHash) { return hash('sha256', json_encode($version->snapshot)) === $snapshotHash; }); if ($existingVersion) { $updates = []; if ($includeAssignments && $existingVersion->assignments === null) { $updates['assignments'] = $assignments; $updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null; } if ($includeScopeTags && $existingVersion->scope_tags === null) { $updates['scope_tags'] = $scopeTags; $updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null; } 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' => $payload, 'assignments' => $assignments, 'scope_tags' => $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' => $payload, 'assignments' => $assignments, 'scope_tags' => $scopeTags, 'metadata' => $captureMetadata, ], ]; } // 5. Create new PolicyVersion with all captured data $metadata = array_merge( ['source' => 'orchestrated_capture'], $metadata, $captureMetadata ); $version = $this->versionService->captureVersion( policy: $policy, payload: $payload, createdBy: $createdBy, metadata: $metadata, assignments: $assignments, scopeTags: $scopeTags, ); 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' => $payload, 'assignments' => $assignments, 'scope_tags' => $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 = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); 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($tenantIdentifier, $policy->external_id, $graphOptions); 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 = collect($rawAssignments) ->pluck('target.deviceAndAppManagementAssignmentFilterId') ->filter() ->unique() ->values() ->all(); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) ->pluck('displayName', 'id') ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); $metadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $metadata['assignments_fetch_failed'] = true; $metadata['assignments_fetch_error'] = $e->getMessage(); 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) { $updates['assignments'] = $assignments; $updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null; } if ($includeScopeTags && $version->scope_tags === null) { $updates['scope_tags'] = $scopeTags; $updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : 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 = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; if ($filterId && isset($filterNames[$filterId])) { $target['assignment_filter_name'] = $filterNames[$filterId]; } $assignment['target'] = $target; return $assignment; }, $assignments); } /** * @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, ]; } }