TenantAtlas/app/Jobs/AddPoliciesToBackupSetJob.php
2026-01-19 18:50:11 +01:00

684 lines
25 KiB
PHP

<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\FoundationSnapshotService;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\SnapshotValidator;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\RunFailureSanitizer;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Throwable;
class AddPoliciesToBackupSetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, int> $policyIds
* @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options
*/
public function __construct(
public int $tenantId,
public int $userId,
public int $backupSetId,
public array $policyIds,
public array $options,
public string $idempotencyKey,
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
OperationRunService $operationRunService,
PolicyCaptureOrchestrator $captureOrchestrator,
FoundationSnapshotService $foundationSnapshots,
SnapshotValidator $snapshotValidator,
): void {
if (! $this->operationRun instanceof OperationRun) {
return;
}
$tenant = Tenant::query()->find($this->tenantId);
$initiator = User::query()->find($this->userId);
$policyIds = $this->normalizePolicyIds($this->policyIds);
$includeAssignments = (bool) ($this->options['include_assignments'] ?? false);
$includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false);
$includeFoundations = (bool) ($this->options['include_foundations'] ?? false);
try {
if (! $tenant instanceof Tenant) {
$this->failRun(
operationRunService: $operationRunService,
tenant: null,
code: 'tenant.not_found',
message: 'Tenant not found for run.',
initiator: $initiator,
);
return;
}
$backupSet = BackupSet::withTrashed()
->where('tenant_id', $tenant->getKey())
->whereKey($this->backupSetId)
->first();
if (! $backupSet) {
$this->failRun(
operationRunService: $operationRunService,
tenant: $tenant,
code: 'backup_set.not_found',
message: 'Backup set not found.',
initiator: $initiator,
);
return;
}
if ($backupSet->trashed()) {
$this->failRun(
operationRunService: $operationRunService,
tenant: $tenant,
code: 'backup_set.archived',
message: 'Backup set is archived.',
initiator: $initiator,
);
return;
}
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_set_id' => (int) $backupSet->getKey(),
'policy_ids' => $policyIds,
'options' => [
'include_assignments' => $includeAssignments,
'include_scope_tags' => $includeScopeTags,
'include_foundations' => $includeFoundations,
],
'idempotency_key' => $this->idempotencyKey,
]),
]);
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
$operationRunService->incrementSummaryCounts($this->operationRun, [
'total' => count($policyIds),
'items' => count($policyIds),
]);
if ($policyIds === []) {
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => 'selection.empty',
'message' => 'No policies selected.',
]],
);
$this->notifyRunFailed($initiator, $tenant, 'No policies selected.');
return;
}
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
$newBackupFailures = [];
$didMutateBackupSet = false;
$backupSetItemMutations = 0;
$foundationMutations = 0;
$foundationFailures = 0;
/** @var array<int, int> $activePolicyIds */
$activePolicyIds = BackupItem::query()
->where('backup_set_id', $backupSet->getKey())
->whereIn('policy_id', $policyIds)
->pluck('policy_id')
->filter()
->map(fn (mixed $value): int => (int) $value)
->values()
->all();
$activePolicyIdSet = array_fill_keys($activePolicyIds, true);
/** @var EloquentCollection<int, BackupItem> $trashedItems */
$trashedItems = BackupItem::onlyTrashed()
->where('backup_set_id', $backupSet->getKey())
->whereIn('policy_id', $policyIds)
->get()
->keyBy('policy_id');
/** @var EloquentCollection<int, Policy> $policies */
$policies = Policy::query()
->where('tenant_id', $tenant->getKey())
->whereIn('id', $policyIds)
->get()
->keyBy('id');
$runFailuresForOperationRun = [];
foreach ($policyIds as $policyId) {
if (isset($activePolicyIdSet[$policyId])) {
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'skipped' => 1,
]);
continue;
}
$trashed = $trashedItems->get($policyId);
if ($trashed instanceof BackupItem) {
$trashed->restore();
$activePolicyIdSet[$policyId] = true;
$didMutateBackupSet = true;
$backupSetItemMutations++;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'succeeded' => 1,
'updated' => 1,
]);
continue;
}
$policy = $policies->get($policyId);
if (! $policy instanceof Policy) {
$newBackupFailures[] = [
'policy_id' => $policyId,
'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'),
'status' => null,
'reason_code' => 'policy_not_found',
];
$didMutateBackupSet = true;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
]);
$runFailuresForOperationRun[] = [
'code' => 'policy.not_found',
'message' => "Policy {$policyId} not found.",
];
continue;
}
if ($policy->ignored_at) {
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'skipped' => 1,
]);
continue;
}
try {
$captureResult = $captureOrchestrator->capture(
policy: $policy,
tenant: $tenant,
includeAssignments: $includeAssignments,
includeScopeTags: $includeScopeTags,
createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null,
metadata: [
'source' => 'backup',
'backup_set_id' => $backupSet->getKey(),
],
);
} catch (Throwable $throwable) {
$reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
$newBackupFailures[] = [
'policy_id' => $policyId,
'reason' => $reason,
'status' => null,
'reason_code' => 'unknown',
];
$didMutateBackupSet = true;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
]);
$runFailuresForOperationRun[] = [
'code' => 'policy.capture_exception',
'message' => $reason,
];
continue;
}
if (isset($captureResult['failure']) && is_array($captureResult['failure'])) {
$failure = $captureResult['failure'];
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
$reasonCode = $this->mapGraphFailureReasonCode($status);
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.'));
$newBackupFailures[] = [
'policy_id' => $policyId,
'reason' => $reason,
'status' => $status,
'reason_code' => $reasonCode,
];
$didMutateBackupSet = true;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
]);
$runFailuresForOperationRun[] = [
'code' => "graph.{$reasonCode}",
'message' => $reason,
];
continue;
}
$version = $captureResult['version'] ?? null;
$captured = $captureResult['captured'] ?? null;
if (! $version || ! is_array($captured)) {
$newBackupFailures[] = [
'policy_id' => $policyId,
'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'),
'status' => null,
'reason_code' => 'unknown',
];
$didMutateBackupSet = true;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
]);
$runFailuresForOperationRun[] = [
'code' => 'capture.missing_payload',
'message' => 'Capture result missing version payload.',
];
continue;
}
$payload = $captured['payload'] ?? [];
$metadata = is_array($captured['metadata'] ?? null) ? $captured['metadata'] : [];
$assignments = is_array($captured['assignments'] ?? null) ? $captured['assignments'] : null;
$scopeTags = is_array($captured['scope_tags'] ?? null) ? $captured['scope_tags'] : null;
if (! is_array($payload)) {
$payload = [];
}
$validation = $snapshotValidator->validate($payload);
$warnings = $validation['warnings'] ?? [];
$odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform);
if ($odataWarning) {
$warnings[] = $odataWarning;
}
if (! empty($warnings)) {
$existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
$metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $warnings)));
}
if (is_array($scopeTags)) {
$metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null;
$metadata['scope_tag_names'] = $scopeTags['names'] ?? null;
}
try {
BackupItem::create([
'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->getKey(),
'policy_id' => $policy->getKey(),
'policy_version_id' => $version->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => $payload,
'metadata' => $metadata,
'assignments' => $assignments,
]);
} catch (QueryException $exception) {
if ((string) $exception->getCode() === '23505') {
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'skipped' => 1,
]);
continue;
}
throw $exception;
}
$activePolicyIdSet[$policyId] = true;
$didMutateBackupSet = true;
$backupSetItemMutations++;
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'succeeded' => 1,
'created' => 1,
]);
}
if ($includeFoundations) {
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
foundationSnapshots: $foundationSnapshots,
tenant: $tenant,
backupSet: $backupSet,
);
if (($foundationOutcome['created'] ?? 0) > 0 || ($foundationOutcome['restored'] ?? 0) > 0) {
$didMutateBackupSet = true;
$foundationMutations = (int) $foundationOutcome['created'] + (int) $foundationOutcome['restored'];
}
if ($foundationFailureEntries !== []) {
$didMutateBackupSet = true;
$foundationFailures = count($foundationFailureEntries);
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
foreach ($foundationFailureEntries as $foundationFailure) {
$runFailuresForOperationRun[] = [
'code' => 'foundation.capture_failed',
'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
];
}
}
}
if ($didMutateBackupSet) {
$allFailures = array_merge($existingBackupFailures, $newBackupFailures);
$mutations = $backupSetItemMutations + $foundationMutations;
$backupSetStatus = match (true) {
$mutations === 0 && count($allFailures) > 0 => 'failed',
count($allFailures) > 0 => 'partial',
default => 'completed',
};
$backupSet->update([
'status' => $backupSetStatus,
'item_count' => $backupSet->items()->count(),
'completed_at' => now(),
'metadata' => ['failures' => $allFailures],
]);
}
$this->operationRun->refresh();
$counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : [];
$failed = (int) ($counts['failed'] ?? 0);
$succeeded = (int) ($counts['succeeded'] ?? 0);
$skipped = (int) ($counts['skipped'] ?? 0);
$outcome = 'succeeded';
if ($failed > 0 && $succeeded > 0) {
$outcome = 'partially_succeeded';
}
if ($failed > 0 && $succeeded === 0) {
$outcome = 'failed';
}
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: $outcome,
failures: $runFailuresForOperationRun,
);
if (! $initiator instanceof User) {
return;
}
$message = "Added {$succeeded} policies";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if ($includeFoundations) {
$message .= ". Foundations: {$foundationMutations} items";
if ($foundationFailures > 0) {
$message .= " ({$foundationFailures} failed)";
}
}
$message .= '.';
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
$notification = Notification::make()
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
->body($message)
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
if ($partial) {
$notification->warning();
} else {
$notification->success();
}
$notification
->sendToDatabase($initiator)
->send();
} catch (Throwable $throwable) {
$this->failRun(
operationRunService: $operationRunService,
tenant: $tenant instanceof Tenant ? $tenant : null,
code: 'exception.unhandled',
message: $throwable->getMessage(),
initiator: $initiator,
);
// TrackOperationRun will catch this throw
throw $throwable;
}
}
/**
* @param array<int, int> $policyIds
* @return array<int>
*/
private function normalizePolicyIds(array $policyIds): array
{
$policyIds = array_values(array_unique(array_map('intval', $policyIds)));
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
sort($policyIds);
return $policyIds;
}
private function failRun(
OperationRunService $operationRunService,
?Tenant $tenant,
string $code,
string $message,
?User $initiator = null,
): void {
$safeMessage = RunFailureSanitizer::sanitizeMessage($message);
$safeCode = RunFailureSanitizer::sanitizeCode($code);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => $safeCode,
'message' => $safeMessage,
]],
);
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
}
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
{
if (! $initiator instanceof User) {
return;
}
$notification = Notification::make()
->title('Add Policies Failed')
->body($reason);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
$notification
->danger()
->sendToDatabase($initiator)
->send();
}
private function mapGraphFailureReasonCode(?int $status): string
{
return match (true) {
$status === 403 => 'graph_forbidden',
in_array($status, [429, 503], true) => 'graph_throttled',
in_array($status, [408, 500, 502, 504], true) => 'graph_transient',
default => 'unknown',
};
}
/**
* @return array{0:array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>},1:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>}
*/
private function captureFoundations(
FoundationSnapshotService $foundationSnapshots,
Tenant $tenant,
BackupSet $backupSet,
): array {
$types = config('tenantpilot.foundation_types', []);
$created = 0;
$restored = 0;
$failures = [];
foreach ($types as $typeConfig) {
$foundationType = $typeConfig['type'] ?? null;
if (! is_string($foundationType) || $foundationType === '') {
continue;
}
$result = $foundationSnapshots->fetchAll($tenant, $foundationType);
foreach (($result['failures'] ?? []) as $failure) {
if (! is_array($failure)) {
continue;
}
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
$reasonCode = $this->mapGraphFailureReasonCode($status);
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.'));
$failures[] = [
'foundation_type' => $foundationType,
'reason' => $reason,
'status' => $status,
'reason_code' => $reasonCode,
];
}
foreach (($result['items'] ?? []) as $snapshot) {
if (! is_array($snapshot)) {
continue;
}
$sourceId = $snapshot['source_id'] ?? null;
if (! is_string($sourceId) || $sourceId === '') {
continue;
}
$existing = BackupItem::withTrashed()
->where('backup_set_id', $backupSet->getKey())
->where('policy_type', $foundationType)
->where('policy_identifier', $sourceId)
->first();
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
$restored++;
}
continue;
}
BackupItem::create([
'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->getKey(),
'policy_id' => null,
'policy_identifier' => $sourceId,
'policy_type' => $foundationType,
'platform' => $typeConfig['platform'] ?? null,
'payload' => $snapshot['payload'] ?? [],
'metadata' => $snapshot['metadata'] ?? [],
]);
$created++;
}
}
return [
[
'created' => $created,
'restored' => $restored,
'failures' => $failures,
],
$failures,
];
}
}