Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
420 lines
16 KiB
PHP
420 lines
16 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\GroupResolver;
|
|
use App\Services\Graph\ScopeTagResolver;
|
|
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,
|
|
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
|
private readonly ScopeTagResolver $scopeTagResolver,
|
|
) {}
|
|
|
|
/**
|
|
* 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 = []
|
|
): array {
|
|
$graphOptions = $tenant->graphOptions();
|
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
|
|
|
// 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();
|
|
|
|
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);
|
|
}
|
|
|
|
// 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) {
|
|
$updates = [];
|
|
|
|
if ($includeAssignments && $existingVersion->assignments === null) {
|
|
$updates['assignments'] = $assignments;
|
|
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
|
|
}
|
|
|
|
if ($includeScopeTags && $existingVersion->scope_tags === null) {
|
|
$updates['scope_tags'] = $scopeTags;
|
|
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : 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' => $payload,
|
|
'assignments' => $assignments,
|
|
'scope_tags' => $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' => $payload,
|
|
'assignments' => $assignments,
|
|
'scope_tags' => $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,
|
|
);
|
|
|
|
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.
|
|
*/
|
|
public function ensureVersionHasAssignments(
|
|
PolicyVersion $version,
|
|
Tenant $tenant,
|
|
Policy $policy,
|
|
bool $includeAssignments = false,
|
|
bool $includeScopeTags = false
|
|
): PolicyVersion {
|
|
$graphOptions = $tenant->graphOptions();
|
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
|
|
|
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();
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|