TenantAtlas/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php
ahmido acc8947384 feat: harden governance action semantics (#229)
## Summary
- add the Spec 194 governance action catalog, friction classes, reason policies, and regression guards
- align exception, review, evidence, finding, tenant, provider connection, and system run actions to the shared semantics model
- add focused feature, RBAC, audit, unit, and browser coverage, including the tenant detail triage header consistency update

## Verification
- ran the focused Spec 194 verification pack from the quickstart and task plan
- ran targeted tenant triage coverage after the detail-header update
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Filament Notes
- Filament v5 / Livewire v4 compliance preserved
- provider registration remains in `apps/platform/bootstrap/providers.php`
- globally searchable resources were not changed
- destructive actions remain confirmation-gated and server-authorized
- no new Filament assets were introduced; the existing `cd apps/platform && php artisan filament:assets` deploy step stays unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #229
2026-04-12 21:21:44 +00:00

214 lines
6.8 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',
'tenant.review.compose',
];
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',
'tenant.review.compose',
];
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, string $reason): OperationRun
{
if (! $this->canCancel($run)) {
throw new InvalidArgumentException('Operation run is not cancelable.');
}
$reason = $this->validatedReason($reason, 'reason');
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'cancelled_at' => now()->toISOString(),
'cancel_reason' => $reason,
'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.',
'reason' => $reason,
],
],
);
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.cancel',
metadata: [
'operation_type' => (string) $run->type,
'reason' => $reason,
],
run: $cancelledRun,
);
return $cancelledRun;
}
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
{
$reason = $this->validatedReason($reason, 'reason');
$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;
}
private function validatedReason(string $reason, string $field): string
{
$reason = trim($reason);
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
throw new InvalidArgumentException(sprintf('%s must be between 5 and 500 characters.', $field));
}
return $reason;
}
}