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
209 lines
6.9 KiB
PHP
209 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Baselines;
|
|
|
|
use App\Models\Policy;
|
|
use App\Models\Tenant;
|
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
|
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
|
use Throwable;
|
|
|
|
final class BaselineContentCapturePhase
|
|
{
|
|
public function __construct(
|
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
|
) {}
|
|
|
|
/**
|
|
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
|
*
|
|
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
|
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
|
* @return array{
|
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
|
* gaps: array<string, int>,
|
|
* resume_token: ?string
|
|
* }
|
|
*/
|
|
public function capture(
|
|
Tenant $tenant,
|
|
array $subjects,
|
|
PolicyVersionCapturePurpose $purpose,
|
|
array $budgets,
|
|
?string $resumeToken = null,
|
|
?int $operationRunId = null,
|
|
?int $baselineProfileId = null,
|
|
?string $createdBy = null,
|
|
): array {
|
|
$subjects = array_values($subjects);
|
|
|
|
$maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0));
|
|
$maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1));
|
|
$maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0));
|
|
|
|
$offset = 0;
|
|
|
|
if (is_string($resumeToken) && $resumeToken !== '') {
|
|
$state = BaselineEvidenceResumeToken::decode($resumeToken) ?? [];
|
|
$offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0;
|
|
}
|
|
|
|
if ($offset >= count($subjects)) {
|
|
$offset = 0;
|
|
}
|
|
|
|
$remaining = array_slice($subjects, $offset);
|
|
$batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : [];
|
|
|
|
$stats = [
|
|
'requested' => count($batch),
|
|
'succeeded' => 0,
|
|
'skipped' => 0,
|
|
'failed' => 0,
|
|
'throttled' => 0,
|
|
];
|
|
|
|
/** @var array<string, int> $gaps */
|
|
$gaps = [];
|
|
|
|
/**
|
|
* @var array<string, true> $seen
|
|
*/
|
|
$seen = [];
|
|
|
|
foreach (array_chunk($batch, $maxConcurrency) as $chunk) {
|
|
foreach ($chunk as $subject) {
|
|
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
|
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
|
|
|
if ($policyType === '' || $externalId === '') {
|
|
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
|
$stats['failed']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$subjectKey = $policyType.'|'.$externalId;
|
|
|
|
if (isset($seen[$subjectKey])) {
|
|
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
|
$stats['skipped']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$seen[$subjectKey] = true;
|
|
|
|
$policy = Policy::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('policy_type', $policyType)
|
|
->where('external_id', $externalId)
|
|
->first();
|
|
|
|
if (! $policy instanceof Policy) {
|
|
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
|
$stats['failed']++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$attempt = 0;
|
|
$result = null;
|
|
|
|
while (true) {
|
|
try {
|
|
$result = $this->captureOrchestrator->capture(
|
|
policy: $policy,
|
|
tenant: $tenant,
|
|
includeAssignments: true,
|
|
includeScopeTags: true,
|
|
createdBy: $createdBy,
|
|
metadata: [
|
|
'capture_source' => 'baseline_evidence',
|
|
],
|
|
capturePurpose: $purpose,
|
|
operationRunId: $operationRunId,
|
|
baselineProfileId: $baselineProfileId,
|
|
);
|
|
} catch (Throwable $throwable) {
|
|
$result = [
|
|
'failure' => [
|
|
'reason' => $throwable->getMessage(),
|
|
'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null,
|
|
],
|
|
];
|
|
}
|
|
|
|
if (! (is_array($result) && array_key_exists('failure', $result))) {
|
|
$stats['succeeded']++;
|
|
|
|
break;
|
|
}
|
|
|
|
$failure = is_array($result['failure'] ?? null) ? $result['failure'] : [];
|
|
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
|
|
|
$isThrottled = in_array($status, [429, 503], true);
|
|
|
|
if ($isThrottled && $attempt < $maxRetries) {
|
|
$delayMs = $this->retryDelayMs($attempt);
|
|
usleep($delayMs * 1000);
|
|
$attempt++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($isThrottled) {
|
|
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
|
$stats['throttled']++;
|
|
} else {
|
|
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
|
$stats['failed']++;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$processed = $offset + count($batch);
|
|
$resumeTokenOut = null;
|
|
|
|
if ($processed < count($subjects)) {
|
|
$resumeTokenOut = BaselineEvidenceResumeToken::encode([
|
|
'offset' => $processed,
|
|
'total' => count($subjects),
|
|
]);
|
|
|
|
$remainingCount = max(0, count($subjects) - $processed);
|
|
if ($remainingCount > 0) {
|
|
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
|
}
|
|
}
|
|
|
|
ksort($gaps);
|
|
|
|
return [
|
|
'stats' => $stats,
|
|
'gaps' => $gaps,
|
|
'resume_token' => $resumeTokenOut,
|
|
];
|
|
}
|
|
|
|
private function retryDelayMs(int $attempt): int
|
|
{
|
|
$attempt = max(0, $attempt);
|
|
|
|
$baseDelayMs = 500;
|
|
$maxDelayMs = 30_000;
|
|
|
|
$delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt));
|
|
$jitterMs = random_int(0, 250);
|
|
|
|
return $delayMs + $jitterMs;
|
|
}
|
|
}
|