TenantAtlas/app/Services/Intune/PolicyCaptureOrchestrator.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

289 lines
10 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 Illuminate\Support\Facades\Log;
/**
* Orchestrates policy capture with assignments and scope tags.
*
* Ensures PolicyVersion is the source of truth, with BackupItem as restore copy.
*/
class PolicyCaptureOrchestrator
{
public function __construct(
private readonly VersionService $versionService,
private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
) {}
/**
* Capture policy snapshot with optional assignments/scope tags.
*
* @param Policy $policy
* @param Tenant $tenant
* @param bool $includeAssignments
* @param bool $includeScopeTags
* @param string|null $createdBy
* @param array $metadata
* @return array ['version' => 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();
}
}