Implements Spec 118 baseline drift engine improvements: - Resumable, budget-aware evidence capture for baseline capture/compare runs (resume token + UI action) - “Why no findings?” reason-code driven explanations and richer run context panels - Baseline Snapshot resource (list/detail) with fidelity visibility - Retention command + schedule for pruning baseline-purpose PolicyVersions - i18n strings for Baseline Compare landing Verification: - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter=Baseline` (159 passed) Note: - `docs/audits/redaction-audit-2026-03-04.md` left untracked (not part of PR). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #143
447 lines
17 KiB
PHP
447 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
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 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);
|
|
}
|
|
|
|
$redactedPayload = $this->snapshotRedactor->redactPayload($payload);
|
|
$redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments);
|
|
$redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
|
|
|
|
// 4. Check if PolicyVersion with same snapshot already exists (based on redacted content)
|
|
$snapshotHash = hash('sha256', json_encode($redactedPayload));
|
|
|
|
// Find existing version by comparing snapshot content (database-agnostic)
|
|
$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 hash('sha256', json_encode($version->snapshot)) === $snapshotHash;
|
|
});
|
|
|
|
if ($existingVersion) {
|
|
$updates = [];
|
|
|
|
if ($includeAssignments && $existingVersion->assignments === null) {
|
|
$updates['assignments'] = $redactedAssignments;
|
|
$updates['assignments_hash'] = $redactedAssignments ? hash('sha256', json_encode($redactedAssignments)) : null;
|
|
}
|
|
|
|
if ($includeScopeTags && $existingVersion->scope_tags === null) {
|
|
$updates['scope_tags'] = $redactedScopeTags;
|
|
$updates['scope_tags_hash'] = $redactedScopeTags ? hash('sha256', json_encode($redactedScopeTags)) : null;
|
|
}
|
|
|
|
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' => $redactedPayload,
|
|
'assignments' => $redactedAssignments,
|
|
'scope_tags' => $redactedScopeTags,
|
|
'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' => $redactedPayload,
|
|
'assignments' => $redactedAssignments,
|
|
'scope_tags' => $redactedScopeTags,
|
|
'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: $redactedPayload,
|
|
createdBy: $createdBy,
|
|
metadata: $metadata,
|
|
assignments: $redactedAssignments,
|
|
scopeTags: $redactedScopeTags,
|
|
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' => $redactedPayload,
|
|
'assignments' => $redactedAssignments,
|
|
'scope_tags' => $redactedScopeTags,
|
|
'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) {
|
|
$updates['assignments'] = $assignments;
|
|
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
|
|
}
|
|
|
|
if ($includeScopeTags && $version->scope_tags === null) {
|
|
$updates['scope_tags'] = $scopeTags;
|
|
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : 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,
|
|
];
|
|
}
|
|
}
|