TenantAtlas/app/Services/Baselines/BaselineContentCapturePhase.php
ahmido 92704a2f7e Spec 118: Resumable baseline evidence capture + snapshot UX (#143)
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
2026-03-04 22:34:13 +00:00

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;
}
}