PolicyVersion, 'captured' => array] */ public function capture( Policy $policy, Tenant $tenant, bool $includeAssignments = false, bool $includeScopeTags = false, ?string $createdBy = null, array $metadata = [] ): array { // 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($tenant->id, $policy->external_id); if (!empty($rawAssignments)) { $assignments = $rawAssignments; // Resolve groups for orphaned detection $groupIds = collect($rawAssignments) ->pluck('target.groupId') ->filter() ->unique() ->values() ->toArray(); if (!empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds); $captureMetadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']); } $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) { $scopeTags = [ 'ids' => $payload['roleScopeTagIds'] ?? ['0'], 'names' => ['Default'], // Could fetch from Graph if needed ]; } // 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 && $includeAssignments && is_null($existingVersion->assignments)) { // Backfill existing version with assignments (idempotent) $existingVersion->update([ 'assignments' => $assignments, 'scope_tags' => $scopeTags, 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, ]); Log::info('Backfilled existing PolicyVersion with assignments', [ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_id' => $existingVersion->id, 'version_number' => $existingVersion->version_number, ]); return [ 'version' => $existingVersion->fresh(), 'captured' => [ 'payload' => $payload, 'assignments' => $assignments, 'scope_tags' => $scopeTags, 'metadata' => $captureMetadata, ], ]; } if ($existingVersion) { // Reuse existing version without modification 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. * * @param PolicyVersion $version * @param Tenant $tenant * @param Policy $policy * @param bool $includeAssignments * @param bool $includeScopeTags * @return PolicyVersion */ public function ensureVersionHasAssignments( PolicyVersion $version, Tenant $tenant, Policy $policy, bool $includeAssignments = false, bool $includeScopeTags = false ): PolicyVersion { // If version already has assignments, don't overwrite (idempotent) if ($version->assignments !== 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 ?? []; // Fetch assignments if ($includeAssignments) { try { $rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id); if (!empty($rawAssignments)) { $assignments = $rawAssignments; // Resolve groups $groupIds = collect($rawAssignments) ->pluck('target.groupId') ->filter() ->unique() ->values() ->toArray(); if (!empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds); $metadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']); } $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) { // Try to get from snapshot $scopeTags = [ 'ids' => $version->snapshot['roleScopeTagIds'] ?? ['0'], 'names' => ['Default'], ]; } // Update version $version->update([ 'assignments' => $assignments, 'scope_tags' => $scopeTags, 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, 'metadata' => $metadata, ]); Log::info('Version backfilled with assignments', [ 'version_id' => $version->id, 'has_assignments' => !is_null($assignments), 'has_scope_tags' => !is_null($scopeTags), ]); return $version->refresh(); } }