TenantAtlas/app/Services/SystemConsole/OperationRunTriageService.php
ahmido da1adbdeb5 Spec 119: Drift cutover to Baseline Compare (golden master) (#144)
Implements Spec 119 (Drift Golden Master Cutover):

- Baseline Compare is the only drift writer (`source = baseline.compare`).
- Drift findings now store diff-compatible `evidence_jsonb` (summary.kind, baseline/current policy_version_id refs, fidelity + provenance).
- Findings UI renders one-sided diffs for `missing_policy`/`unexpected_policy` when a single ref exists; otherwise shows explicit “diff unavailable”.
- Removes legacy drift generator runtime (jobs/services/UI) and related tests.
- Adds one-time migration to delete legacy drift findings (`finding_type=drift` where source is null or != baseline.compare).
- Scopes baseline capture & landing duplicate warnings to latest completed inventory sync.
- Canonicalizes compliance `scheduledActionsForRule` drift signal and keeps legacy snapshots comparable.

Tests:
- `vendor/bin/sail artisan test --compact` (full suite per tasks)
- Focused pack: BaselinePolicyVersionResolverTest, BaselineCompareDriftEvidenceContractTest, DriftFindingDiffUnavailableTest, LegacyDriftFindingsCleanupMigrationTest, ComplianceNoncomplianceActionsDriftTest

Notes:
- Livewire v4+ / Filament v5 compatible (no legacy APIs).
- No new external dependencies.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #144
2026-03-06 14:30:49 +00:00

201 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\SystemConsole;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use InvalidArgumentException;
final class OperationRunTriageService
{
private const RETRYABLE_TYPES = [
'inventory_sync',
'policy.sync',
'policy.sync_one',
'entra_group_sync',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',
];
private const CANCELABLE_TYPES = [
'inventory_sync',
'policy.sync',
'policy.sync_one',
'entra_group_sync',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',
];
public function __construct(
private readonly OperationRunService $operationRunService,
private readonly SystemConsoleAuditLogger $auditLogger,
) {}
public function canRetry(OperationRun $run): bool
{
return (string) $run->status === OperationRunStatus::Completed->value
&& (string) $run->outcome === OperationRunOutcome::Failed->value
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
}
public function canCancel(OperationRun $run): bool
{
return in_array((string) $run->status, [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true)
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
}
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
{
if (! $this->canRetry($run)) {
throw new InvalidArgumentException('Operation run is not retryable.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'retry_of_run_id' => (int) $run->getKey(),
'retried_at' => now()->toISOString(),
'retried_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
);
$retryRun = OperationRun::query()->create([
'workspace_id' => (int) $run->workspace_id,
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
'user_id' => null,
'initiator_name' => $actor->name ?? 'Platform operator',
'type' => (string) $run->type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
'summary_counts' => [],
'failure_summary' => [],
'context' => $context,
'started_at' => null,
'completed_at' => null,
]);
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.retry',
metadata: [
'source_run_id' => (int) $run->getKey(),
'new_run_id' => (int) $retryRun->getKey(),
'operation_type' => (string) $run->type,
],
run: $retryRun,
);
return $retryRun;
}
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
{
if (! $this->canCancel($run)) {
throw new InvalidArgumentException('Operation run is not cancelable.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'cancelled_at' => now()->toISOString(),
'cancelled_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
);
$run->update([
'context' => $context,
]);
$run->refresh();
$cancelledRun = $this->operationRunService->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'run.cancelled',
'message' => 'Run cancelled by platform operator triage action.',
],
],
);
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.cancel',
metadata: [
'operation_type' => (string) $run->type,
],
run: $cancelledRun,
);
return $cancelledRun;
}
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
{
$reason = trim($reason);
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'investigated' => [
'reason' => $reason,
'investigated_at' => now()->toISOString(),
'investigated_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
],
);
$run->update([
'context' => $context,
]);
$run->refresh();
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.mark_investigated',
metadata: [
'reason' => $reason,
'operation_type' => (string) $run->type,
],
run: $run,
);
return $run;
}
}