Implements spec 094 (assignment fetch/restore observability hardening): - Adds OperationRun tracking for assignment fetch (during backup) and assignment restore (during restore execution) - Normalizes failure codes/reason_code and sanitizes failure messages - Ensures exactly one audit log entry per assignment restore execution - Enforces correct guard/membership vs capability semantics on affected admin surfaces - Switches assignment Graph services to depend on GraphClientInterface Also includes Postgres-only FK defense-in-depth check and a discoverable `composer test:pgsql` runner (scoped to the FK constraint test). Tests: - `vendor/bin/sail artisan test --compact` (passed) - `vendor/bin/sail composer test:pgsql` (passed) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #113
318 lines
11 KiB
PHP
318 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\BackupItem;
|
|
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 App\Services\Providers\MicrosoftGraphOptionsResolver;
|
|
use App\Support\OpsUx\RunFailureSanitizer;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class AssignmentBackupService
|
|
{
|
|
public function __construct(
|
|
private readonly AssignmentFetcher $assignmentFetcher,
|
|
private readonly GroupResolver $groupResolver,
|
|
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
|
private readonly ScopeTagResolver $scopeTagResolver,
|
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
|
private readonly OperationRunService $operationRunService,
|
|
) {}
|
|
|
|
/**
|
|
* Enrich a backup item with assignments and scope tag metadata.
|
|
*
|
|
* @param BackupItem $backupItem The backup item to enrich
|
|
* @param Tenant $tenant Tenant model with credentials
|
|
* @param string $policyType Policy type key (e.g. deviceConfiguration)
|
|
* @param string $policyId Policy ID (external_id from Graph)
|
|
* @param array $policyPayload Full policy payload from Graph
|
|
* @param bool $includeAssignments Whether to fetch and include assignments
|
|
* @return BackupItem Updated backup item with assignments and metadata
|
|
*/
|
|
public function enrichWithAssignments(
|
|
BackupItem $backupItem,
|
|
Tenant $tenant,
|
|
string $policyType,
|
|
string $policyId,
|
|
array $policyPayload,
|
|
bool $includeAssignments = false
|
|
): BackupItem {
|
|
// Extract scope tags from payload (always available in policy)
|
|
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0'];
|
|
$scopeTagNames = $this->resolveScopeTagNames($scopeTagIds, $tenant);
|
|
|
|
$metadata = $backupItem->metadata ?? [];
|
|
$metadata['scope_tag_ids'] = $scopeTagIds;
|
|
$metadata['scope_tag_names'] = $scopeTagNames;
|
|
|
|
// Only fetch assignments if explicitly requested
|
|
if (! $includeAssignments) {
|
|
$metadata['assignment_count'] = 0;
|
|
$backupItem->update([
|
|
'assignments' => null,
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
return $backupItem->refresh();
|
|
}
|
|
|
|
// Fetch assignments from Graph API
|
|
$graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant);
|
|
$tenantId = (string) ($graphOptions['tenant'] ?? '');
|
|
$assignments = $this->assignmentFetcher->fetch(
|
|
$policyType,
|
|
$tenantId,
|
|
$policyId,
|
|
$graphOptions,
|
|
false,
|
|
$policyPayload['@odata.type'] ?? null,
|
|
);
|
|
|
|
if (empty($assignments)) {
|
|
// No assignments or fetch failed
|
|
$metadata['assignment_count'] = 0;
|
|
$metadata['assignments_fetch_failed'] = true;
|
|
$metadata['has_orphaned_assignments'] = false;
|
|
|
|
$backupItem->update([
|
|
'assignments' => [], // Return empty array instead of null
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
|
|
|
Log::warning('No assignments fetched for policy', [
|
|
'tenant_id' => $tenantId,
|
|
'policy_id' => $policyId,
|
|
'backup_item_id' => $backupItem->id,
|
|
]);
|
|
|
|
return $backupItem->refresh();
|
|
}
|
|
|
|
// Extract group IDs and resolve for orphan detection
|
|
$groupIds = $this->extractGroupIds($assignments);
|
|
$resolvedGroups = [];
|
|
$hasOrphanedGroups = false;
|
|
|
|
if (! empty($groupIds)) {
|
|
$resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantId, $graphOptions);
|
|
$hasOrphanedGroups = collect($resolvedGroups)
|
|
->contains(fn (array $group) => $group['orphaned'] ?? false);
|
|
}
|
|
|
|
$filterIds = $this->extractAssignmentFilterIds($assignments);
|
|
|
|
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
|
|
$filterNames = collect($filters)
|
|
->pluck('displayName', 'id')
|
|
->all();
|
|
|
|
$assignments = $this->enrichAssignments($assignments, $resolvedGroups, $filterNames);
|
|
|
|
// Update backup item with assignments and metadata
|
|
$metadata['assignment_count'] = count($assignments);
|
|
$metadata['assignments_fetch_failed'] = false;
|
|
$metadata['has_orphaned_assignments'] = $hasOrphanedGroups;
|
|
|
|
$backupItem->update([
|
|
'assignments' => $assignments,
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
$this->recordFetchOperationRun($backupItem, $tenant, $metadata);
|
|
|
|
Log::info('Assignments enriched for backup item', [
|
|
'tenant_id' => $tenantId,
|
|
'policy_id' => $policyId,
|
|
'backup_item_id' => $backupItem->id,
|
|
'assignment_count' => count($assignments),
|
|
'has_orphaned' => $hasOrphanedGroups,
|
|
]);
|
|
|
|
return $backupItem->refresh();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $captureMetadata
|
|
*/
|
|
public function recordFetchOperationRun(BackupItem $backupItem, Tenant $tenant, array $captureMetadata = []): void
|
|
{
|
|
$run = $this->operationRunService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: 'assignments.fetch',
|
|
identityInputs: [
|
|
'backup_item_id' => (int) $backupItem->getKey(),
|
|
],
|
|
context: [
|
|
'backup_set_id' => (int) $backupItem->backup_set_id,
|
|
'backup_item_id' => (int) $backupItem->getKey(),
|
|
'policy_id' => is_numeric($backupItem->policy_id) ? (int) $backupItem->policy_id : null,
|
|
'policy_identifier' => (string) $backupItem->policy_identifier,
|
|
],
|
|
);
|
|
|
|
if ($run->status === 'completed') {
|
|
return;
|
|
}
|
|
|
|
$this->operationRunService->updateRun($run, 'running');
|
|
|
|
$fetchFailed = (bool) ($captureMetadata['assignments_fetch_failed'] ?? false);
|
|
|
|
$reasonCandidate = $captureMetadata['assignments_fetch_error_code']
|
|
?? $captureMetadata['assignments_fetch_error']
|
|
?? ProviderReasonCodes::UnknownError;
|
|
|
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode(
|
|
$this->normalizeReasonCandidate($reasonCandidate)
|
|
);
|
|
|
|
$this->operationRunService->updateRun(
|
|
$run,
|
|
status: 'completed',
|
|
outcome: $fetchFailed ? 'failed' : 'succeeded',
|
|
summaryCounts: [
|
|
'total' => 1,
|
|
'processed' => $fetchFailed ? 0 : 1,
|
|
'failed' => $fetchFailed ? 1 : 0,
|
|
],
|
|
failures: $fetchFailed
|
|
? [[
|
|
'code' => 'assignments.fetch_failed',
|
|
'reason_code' => $reasonCode,
|
|
'message' => (string) ($captureMetadata['assignments_fetch_error'] ?? 'Assignments fetch failed'),
|
|
]]
|
|
: [],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve scope tag IDs to display names.
|
|
*/
|
|
private function resolveScopeTagNames(array $scopeTagIds, Tenant $tenant): array
|
|
{
|
|
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant);
|
|
|
|
$names = [];
|
|
foreach ($scopeTagIds as $id) {
|
|
$scopeTag = collect($scopeTags)->firstWhere('id', $id);
|
|
$names[] = $scopeTag['displayName'] ?? "Unknown (ID: {$id})";
|
|
}
|
|
|
|
return $names;
|
|
}
|
|
|
|
/**
|
|
* Extract group IDs from assignment array.
|
|
*/
|
|
private function extractGroupIds(array $assignments): array
|
|
{
|
|
$groupIds = [];
|
|
|
|
foreach ($assignments as $assignment) {
|
|
$target = $assignment['target'] ?? [];
|
|
$odataType = $target['@odata.type'] ?? '';
|
|
|
|
if (in_array($odataType, [
|
|
'#microsoft.graph.groupAssignmentTarget',
|
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
|
], true) && isset($target['groupId'])) {
|
|
$groupIds[] = $target['groupId'];
|
|
}
|
|
}
|
|
|
|
return array_unique($groupIds);
|
|
}
|
|
|
|
/**
|
|
* @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));
|
|
}
|
|
|
|
private function normalizeReasonCandidate(mixed $candidate): string
|
|
{
|
|
if (! is_string($candidate) && ! is_numeric($candidate)) {
|
|
return ProviderReasonCodes::UnknownError;
|
|
}
|
|
|
|
$raw = trim((string) $candidate);
|
|
|
|
if ($raw === '') {
|
|
return ProviderReasonCodes::UnknownError;
|
|
}
|
|
|
|
$raw = preg_replace('/(?<!^)[A-Z]/', '_$0', $raw) ?? $raw;
|
|
$raw = strtolower($raw);
|
|
$raw = str_replace([' ', '-', '.', '/'], '_', $raw);
|
|
$raw = preg_replace('/_+/', '_', $raw) ?? $raw;
|
|
|
|
return trim($raw, '_');
|
|
}
|
|
}
|