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' => $payload, 'metadata' => $metadata, 'assignments' => $assignments, 'scope_tags' => $scopeTags, 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, ]); $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 captureFromGraph( Tenant $tenant, Policy $policy, ?string $createdBy = null, array $metadata = [], bool $includeAssignments = true, bool $includeScopeTags = true, ): PolicyVersion { $graphOptions = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); $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']; $assignments = null; $scopeTags = null; $assignmentMetadata = []; if ($includeAssignments) { try { $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $assignments = $rawAssignments; // Resolve groups $groupIds = collect($rawAssignments) ->pluck('target.groupId') ->filter() ->unique() ->values() ->toArray(); $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); $assignmentMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $assignmentMetadata['assignments_fetch_failed'] = true; $assignmentMetadata['assignments_fetch_error'] = $e->getMessage(); } } if ($includeScopeTags) { $scopeTagIds = $payload['roleScopeTagIds'] ?? ['0']; $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); } $metadata = array_merge( ['source' => 'version_capture'], $metadata, $assignmentMetadata ); return $this->captureVersion( policy: $policy, payload: $payload, createdBy: $createdBy, metadata: $metadata, assignments: $assignments, scopeTags: $scopeTags, ); } /** * @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, ]; } private function nextVersionNumber(Policy $policy): int { $current = PolicyVersion::query() ->where('policy_id', $policy->id) ->max('version_number'); return (int) ($current ?? 0) + 1; } }