TenantAtlas/app/Services/Intune/PolicyCaptureOrchestrator.php
2026-03-07 17:41:55 +01:00

533 lines
21 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\AssignmentFilterResolver;
use App\Services\Graph\GraphException;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Support\Baselines\PolicyVersionCapturePurpose;
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 PolicySnapshotRedactor $snapshotRedactor,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
private readonly ScopeTagResolver $scopeTagResolver,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
) {}
/**
* Capture policy snapshot with optional assignments/scope tags.
*
* @return array ['version' => PolicyVersion, 'captured' => array]
*/
public function capture(
Policy $policy,
Tenant $tenant,
bool $includeAssignments = false,
bool $includeScopeTags = false,
?string $createdBy = null,
array $metadata = [],
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
?int $operationRunId = null,
?int $baselineProfileId = null,
): array {
$graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant);
$tenantIdentifier = (string) ($graphOptions['tenant'] ?? '');
// 1. Fetch policy snapshot
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) {
return [
'failure' => $snapshot['failure'],
];
}
$payload = $snapshot['payload'];
$assignments = null;
$scopeTags = null;
$captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
if ($snapshotWarnings !== []) {
$existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : [];
$captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
}
// 2. Fetch assignments if requested
if ($includeAssignments) {
try {
$rawAssignments = $this->assignmentFetcher->fetch(
$policy->policy_type,
$tenantIdentifier,
$policy->external_id,
$graphOptions,
true,
$payload['@odata.type'] ?? null,
);
$captureMetadata['assignments_fetched'] = true;
$captureMetadata['assignments_count'] = count($rawAssignments);
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 = $this->extractAssignmentFilterIds($rawAssignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
}
} catch (\Throwable $e) {
$captureMetadata['assignments_fetch_failed'] = true;
$captureMetadata['assignments_fetch_error'] = $e->getMessage();
$captureMetadata['assignments_fetch_error_code'] = $e instanceof GraphException
? ($e->status ?? null)
: (is_numeric($e->getCode()) ? (int) $e->getCode() : null);
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);
}
$protectedSnapshot = $this->snapshotRedactor->protect(
workspaceId: (int) $tenant->workspace_id,
payload: $payload,
assignments: $assignments,
scopeTags: $scopeTags,
);
$snapshotHash = $this->snapshotContractHash(
snapshot: $protectedSnapshot->snapshot,
snapshotFingerprints: $protectedSnapshot->secretFingerprints['snapshot'],
redactionVersion: $protectedSnapshot->redactionVersion,
);
$existingVersion = PolicyVersion::query()
->where('policy_id', $policy->id)
->where('capture_purpose', $capturePurpose->value)
->when(
$capturePurpose !== PolicyVersionCapturePurpose::Backup && $baselineProfileId !== null,
fn ($query) => $query->where('baseline_profile_id', $baselineProfileId),
)
->get()
->first(function ($version) use ($snapshotHash) {
return $this->snapshotContractHash(
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
) === $snapshotHash;
});
if ($existingVersion) {
$updates = [];
if ($includeAssignments && $existingVersion->assignments === null) {
$updates['assignments'] = $protectedSnapshot->assignments;
$updates['assignments_hash'] = $protectedSnapshot->assignments
? hash('sha256', json_encode($protectedSnapshot->assignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($includeScopeTags && $existingVersion->scope_tags === null) {
$updates['scope_tags'] = $protectedSnapshot->scopeTags;
$updates['scope_tags_hash'] = $protectedSnapshot->scopeTags
? hash('sha256', json_encode($protectedSnapshot->scopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($updates !== []) {
$secretFingerprints = is_array($existingVersion->secret_fingerprints)
? $existingVersion->secret_fingerprints
: ProtectedSnapshotResult::emptyFingerprints();
if (array_key_exists('assignments', $updates)) {
$secretFingerprints['assignments'] = $protectedSnapshot->secretFingerprints['assignments'];
}
if (array_key_exists('scope_tags', $updates)) {
$secretFingerprints['scope_tags'] = $protectedSnapshot->secretFingerprints['scope_tags'];
}
$updates['secret_fingerprints'] = $secretFingerprints;
}
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' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->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' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->scopeTags,
'metadata' => $captureMetadata,
],
];
}
// 5. Create new PolicyVersion with all captured data
$metadata = array_merge(
['capture_source' => 'orchestrated_capture'],
$metadata,
$captureMetadata,
);
$version = $this->versionService->captureVersion(
policy: $policy,
payload: $payload,
createdBy: $createdBy,
metadata: $metadata,
assignments: $assignments,
scopeTags: $scopeTags,
capturePurpose: $capturePurpose,
operationRunId: $operationRunId,
baselineProfileId: $baselineProfileId,
);
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' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->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 = $this->graphOptionsResolver->resolveForTenant($tenant);
$tenantIdentifier = (string) ($graphOptions['tenant'] ?? '');
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(
$policy->policy_type,
$tenantIdentifier,
$policy->external_id,
$graphOptions,
true,
is_array($version->snapshot) ? ($version->snapshot['@odata.type'] ?? null) : null,
);
$metadata['assignments_fetched'] = true;
$metadata['assignments_count'] = count($rawAssignments);
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 = $this->extractAssignmentFilterIds($rawAssignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
}
} catch (\Throwable $e) {
$metadata['assignments_fetch_failed'] = true;
$metadata['assignments_fetch_error'] = $e->getMessage();
$metadata['assignments_fetch_error_code'] = $e instanceof GraphException
? ($e->status ?? null)
: (is_numeric($e->getCode()) ? (int) $e->getCode() : null);
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) {
$redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments);
$updates['assignments'] = $redactedAssignments;
$updates['assignments_hash'] = $redactedAssignments
? hash('sha256', json_encode($redactedAssignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($includeScopeTags && $version->scope_tags === null) {
$redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
$updates['scope_tags'] = $redactedScopeTags;
$updates['scope_tags_hash'] = $redactedScopeTags
? hash('sha256', json_encode($redactedScopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: 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<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
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 = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
if ($filterId) {
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
if ($filterType) {
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
}
if (isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
}
$assignment['target'] = $target;
return $assignment;
}, $assignments);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
private function extractAssignmentFilterIds(array $assignments): array
{
$filterIds = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
if (is_string($filterId) && $filterId !== '') {
$filterIds[] = $filterId;
}
}
return array_values(array_unique($filterIds));
}
/**
* @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}
*/
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,
];
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, string> $snapshotFingerprints
*/
private function snapshotContractHash(array $snapshot, array $snapshotFingerprints, ?int $redactionVersion): string
{
return hash(
'sha256',
json_encode(
$this->normalizeHashValue([
'snapshot' => $snapshot,
'secret_fingerprints' => $snapshotFingerprints,
'redaction_version' => $redactionVersion,
]),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
),
);
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$fingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $fingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
private function normalizeHashValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->normalizeHashValue($item), $value);
}
ksort($value);
foreach ($value as $key => $item) {
$value[$key] = $this->normalizeHashValue($item);
}
return $value;
}
}