TenantAtlas/app/Services/Intune/VersionService.php
Ahmed Darrazi c3bdcf4d2d feat(004): implement PolicyCaptureOrchestrator for assignment consistency
BREAKING CHANGE: Assignment capture flow completely refactored

Core Changes:
- Created PolicyCaptureOrchestrator service for centralized capture coordination
- Refactored BackupService to use orchestrator (version-first approach)
- Fixed domain model bug: PolicyVersion now stores assignments (source of truth)
- BackupItem references PolicyVersion and copies assignments for restore

Database:
- Added assignments, scope_tags, assignments_hash, scope_tags_hash to policy_versions
- Added policy_version_id foreign key to backup_items
- Migrations: 2025_12_22_171525, 2025_12_22_171545

Services:
- PolicyCaptureOrchestrator: Intelligent version reuse, idempotent backfilling
- VersionService: Enhanced to capture assignments during version creation
- BackupService: Uses orchestrator, version-first capture flow

UI:
- Moved assignments widget from Policy to PolicyVersion view
- Created PolicyVersionAssignmentsWidget Livewire component
- Updated BackupItemsRelationManager columns for new assignment fields

Tests:
- Deleted BackupWithAssignmentsTest (old behavior)
- Created BackupWithAssignmentsConsistencyTest (4 tests, all passing)
- Fixed AssignmentFetcherTest and GroupResolverTest for GraphResponse
- All 162 tests passing

Issue: Assignments/scope tags not displaying in BackupSet items table (UI only)
Status: Database contains correct data, UI column definitions need adjustment
2025-12-22 20:19:10 +01:00

137 lines
4.4 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use Carbon\CarbonImmutable;
class VersionService
{
public function __construct(
private readonly AuditLogger $auditLogger,
private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
) {}
public function captureVersion(
Policy $policy,
array $payload,
?string $createdBy = null,
array $metadata = [],
?array $assignments = null,
?array $scopeTags = null,
): PolicyVersion {
$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' => $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 = [],
): PolicyVersion {
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) {
$reason = $snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot';
throw new \RuntimeException($reason);
}
// Fetch assignments from Graph
$assignments = [];
$scopeTags = [];
$assignmentMetadata = [];
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id);
if (! empty($rawAssignments)) {
$assignments = $rawAssignments;
// Resolve groups and scope tags
$groupIds = collect($rawAssignments)
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
$resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds);
$scopeTags = [
'ids' => $policy->roleScopeTagIds ?? ['0'],
'names' => ['Default'], // Could be fetched from Graph if needed
];
$assignmentMetadata['has_orphaned_assignments'] = ! empty($resolvedGroups['orphaned']);
$assignmentMetadata['assignments_count'] = count($rawAssignments);
}
} catch (\Throwable $e) {
$assignmentMetadata['assignments_fetch_failed'] = true;
$assignmentMetadata['assignments_fetch_error'] = $e->getMessage();
}
$metadata = array_merge(
['source' => 'version_capture'],
$metadata,
$assignmentMetadata
);
return $this->captureVersion(
policy: $policy,
payload: $snapshot['payload'],
createdBy: $createdBy,
metadata: $metadata,
assignments: ! empty($assignments) ? $assignments : null,
scopeTags: ! empty($scopeTags) ? $scopeTags : null,
);
}
private function nextVersionNumber(Policy $policy): int
{
$current = PolicyVersion::query()
->where('policy_id', $policy->id)
->max('version_number');
return (int) ($current ?? 0) + 1;
}
}