Implements Spec 115 (Baseline Operability & Alert Integration). Key changes - Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares) - Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics - Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle - Baseline Compare UX: shared stats layer + landing/widget consistency Notes - Livewire v4 / Filament v5 compatible. - Destructive-like actions require confirmation (no new destructive actions added here). Tests - `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #140
581 lines
22 KiB
PHP
581 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Jobs\Middleware\TrackOperationRun;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Baselines\BaselineAutoCloseService;
|
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
|
use App\Services\Drift\DriftHasher;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Support\Baselines\BaselineScope;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use RuntimeException;
|
|
|
|
class CompareBaselineToTenantJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public ?OperationRun $operationRun = null;
|
|
|
|
public function __construct(
|
|
public OperationRun $run,
|
|
) {
|
|
$this->operationRun = $run;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, object>
|
|
*/
|
|
public function middleware(): array
|
|
{
|
|
return [new TrackOperationRun];
|
|
}
|
|
|
|
public function handle(
|
|
DriftHasher $driftHasher,
|
|
BaselineSnapshotIdentity $snapshotIdentity,
|
|
AuditLogger $auditLogger,
|
|
OperationRunService $operationRunService,
|
|
?SettingsResolver $settingsResolver = null,
|
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
|
): void {
|
|
$settingsResolver ??= app(SettingsResolver::class);
|
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
|
|
|
if (! $this->operationRun instanceof OperationRun) {
|
|
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
|
|
|
return;
|
|
}
|
|
|
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
|
$snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0);
|
|
|
|
$profile = BaselineProfile::query()->find($profileId);
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
|
|
}
|
|
|
|
$tenant = Tenant::query()->find($this->operationRun->tenant_id);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
throw new RuntimeException("Workspace #{$tenant->workspace_id} not found.");
|
|
}
|
|
|
|
$initiator = $this->operationRun->user_id
|
|
? User::query()->find($this->operationRun->user_id)
|
|
: null;
|
|
|
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
|
|
|
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
|
|
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant);
|
|
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId);
|
|
|
|
$driftResults = $this->computeDrift(
|
|
$baselineItems,
|
|
$currentItems,
|
|
$this->resolveSeverityMapping($workspace, $settingsResolver),
|
|
);
|
|
|
|
$upsertResult = $this->upsertFindings(
|
|
$driftHasher,
|
|
$tenant,
|
|
$profile,
|
|
$scopeKey,
|
|
$driftResults,
|
|
);
|
|
|
|
$severityBreakdown = $this->countBySeverity($driftResults);
|
|
|
|
$summaryCounts = [
|
|
'total' => count($driftResults),
|
|
'processed' => count($driftResults),
|
|
'succeeded' => (int) $upsertResult['processed_count'],
|
|
'failed' => 0,
|
|
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
|
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
|
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
|
|
'findings_created' => (int) $upsertResult['created_count'],
|
|
'findings_reopened' => (int) $upsertResult['reopened_count'],
|
|
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
|
|
];
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
|
|
$resolvedCount = 0;
|
|
|
|
if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) {
|
|
$resolvedCount = $baselineAutoCloseService->resolveStaleFindings(
|
|
tenant: $tenant,
|
|
baselineProfileId: (int) $profile->getKey(),
|
|
seenFingerprints: $upsertResult['seen_fingerprints'],
|
|
currentOperationRunId: (int) $this->operationRun->getKey(),
|
|
);
|
|
|
|
$summaryCounts['findings_resolved'] = $resolvedCount;
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: $summaryCounts,
|
|
);
|
|
}
|
|
|
|
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
$updatedContext['result'] = [
|
|
'findings_total' => count($driftResults),
|
|
'findings_upserted' => (int) $upsertResult['processed_count'],
|
|
'findings_resolved' => $resolvedCount,
|
|
'severity_breakdown' => $severityBreakdown,
|
|
];
|
|
$this->operationRun->update(['context' => $updatedContext]);
|
|
|
|
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
|
|
}
|
|
|
|
/**
|
|
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
|
|
*
|
|
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
|
*/
|
|
private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
|
|
{
|
|
$items = [];
|
|
|
|
$query = BaselineSnapshotItem::query()
|
|
->where('baseline_snapshot_id', $snapshotId);
|
|
|
|
if (! $scope->isEmpty()) {
|
|
$query->whereIn('policy_type', $scope->policyTypes);
|
|
}
|
|
|
|
$query
|
|
->orderBy('id')
|
|
->chunk(500, function ($snapshotItems) use (&$items): void {
|
|
foreach ($snapshotItems as $item) {
|
|
$key = $item->policy_type.'|'.$item->subject_external_id;
|
|
$items[$key] = [
|
|
'subject_type' => (string) $item->subject_type,
|
|
'subject_external_id' => (string) $item->subject_external_id,
|
|
'policy_type' => (string) $item->policy_type,
|
|
'baseline_hash' => (string) $item->baseline_hash,
|
|
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
|
];
|
|
}
|
|
});
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Load current inventory items keyed by "policy_type|external_id".
|
|
*
|
|
* @return array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}>
|
|
*/
|
|
private function loadCurrentInventory(
|
|
Tenant $tenant,
|
|
BaselineScope $scope,
|
|
BaselineSnapshotIdentity $snapshotIdentity,
|
|
?int $latestInventorySyncRunId = null,
|
|
): array {
|
|
$query = InventoryItem::query()
|
|
->where('tenant_id', $tenant->getKey());
|
|
|
|
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
|
}
|
|
|
|
if (! $scope->isEmpty()) {
|
|
$query->whereIn('policy_type', $scope->policyTypes);
|
|
}
|
|
|
|
$items = [];
|
|
|
|
$query->orderBy('policy_type')
|
|
->orderBy('external_id')
|
|
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
|
|
foreach ($inventoryItems as $inventoryItem) {
|
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
|
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
|
|
|
|
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
|
|
$items[$key] = [
|
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
|
'policy_type' => (string) $inventoryItem->policy_type,
|
|
'current_hash' => $currentHash,
|
|
'meta_jsonb' => [
|
|
'display_name' => $inventoryItem->display_name,
|
|
'category' => $inventoryItem->category,
|
|
'platform' => $inventoryItem->platform,
|
|
],
|
|
];
|
|
}
|
|
});
|
|
|
|
return $items;
|
|
}
|
|
|
|
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
|
|
{
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('type', OperationRunType::InventorySync->value)
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->orderByDesc('completed_at')
|
|
->orderByDesc('id')
|
|
->first(['id']);
|
|
|
|
$runId = $run?->getKey();
|
|
|
|
return is_numeric($runId) ? (int) $runId : null;
|
|
}
|
|
|
|
/**
|
|
* Compare baseline items vs current inventory and produce drift results.
|
|
*
|
|
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
|
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
|
|
* @param array<string, string> $severityMapping
|
|
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
|
|
*/
|
|
private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array
|
|
{
|
|
$drift = [];
|
|
|
|
foreach ($baselineItems as $key => $baselineItem) {
|
|
if (! array_key_exists($key, $currentItems)) {
|
|
$drift[] = [
|
|
'change_type' => 'missing_policy',
|
|
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
|
'subject_type' => $baselineItem['subject_type'],
|
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => '',
|
|
'evidence' => [
|
|
'change_type' => 'missing_policy',
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
|
],
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$currentItem = $currentItems[$key];
|
|
|
|
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
|
|
$drift[] = [
|
|
'change_type' => 'different_version',
|
|
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
|
'subject_type' => $baselineItem['subject_type'],
|
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => $currentItem['current_hash'],
|
|
'evidence' => [
|
|
'change_type' => 'different_version',
|
|
'policy_type' => $baselineItem['policy_type'],
|
|
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
|
'current_hash' => $currentItem['current_hash'],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
foreach ($currentItems as $key => $currentItem) {
|
|
if (! array_key_exists($key, $baselineItems)) {
|
|
$drift[] = [
|
|
'change_type' => 'unexpected_policy',
|
|
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
|
'subject_type' => 'policy',
|
|
'subject_external_id' => $currentItem['subject_external_id'],
|
|
'policy_type' => $currentItem['policy_type'],
|
|
'baseline_hash' => '',
|
|
'current_hash' => $currentItem['current_hash'],
|
|
'evidence' => [
|
|
'change_type' => 'unexpected_policy',
|
|
'policy_type' => $currentItem['policy_type'],
|
|
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $drift;
|
|
}
|
|
|
|
/**
|
|
* Upsert drift findings using stable fingerprints.
|
|
*
|
|
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
|
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
|
|
*/
|
|
private function upsertFindings(
|
|
DriftHasher $driftHasher,
|
|
Tenant $tenant,
|
|
BaselineProfile $profile,
|
|
string $scopeKey,
|
|
array $driftResults,
|
|
): array {
|
|
$tenantId = (int) $tenant->getKey();
|
|
$observedAt = CarbonImmutable::now();
|
|
$processedCount = 0;
|
|
$createdCount = 0;
|
|
$reopenedCount = 0;
|
|
$unchangedCount = 0;
|
|
$seenFingerprints = [];
|
|
|
|
foreach ($driftResults as $driftItem) {
|
|
$fingerprint = $driftHasher->fingerprint(
|
|
tenantId: $tenantId,
|
|
scopeKey: $scopeKey,
|
|
subjectType: $driftItem['subject_type'],
|
|
subjectExternalId: $driftItem['subject_external_id'],
|
|
changeType: $driftItem['change_type'],
|
|
baselineHash: $driftItem['baseline_hash'],
|
|
currentHash: $driftItem['current_hash'],
|
|
);
|
|
|
|
$seenFingerprints[] = $fingerprint;
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('fingerprint', $fingerprint)
|
|
->first();
|
|
|
|
$isNewFinding = ! $finding instanceof Finding;
|
|
|
|
if ($isNewFinding) {
|
|
$finding = new Finding;
|
|
} else {
|
|
$this->observeFinding(
|
|
finding: $finding,
|
|
observedAt: $observedAt,
|
|
currentOperationRunId: (int) $this->operationRun->getKey(),
|
|
);
|
|
}
|
|
|
|
$finding->forceFill([
|
|
'tenant_id' => $tenantId,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
'source' => 'baseline.compare',
|
|
'scope_key' => $scopeKey,
|
|
'subject_type' => $driftItem['subject_type'],
|
|
'subject_external_id' => $driftItem['subject_external_id'],
|
|
'severity' => $driftItem['severity'],
|
|
'fingerprint' => $fingerprint,
|
|
'evidence_jsonb' => $driftItem['evidence'],
|
|
'baseline_operation_run_id' => null,
|
|
'current_operation_run_id' => (int) $this->operationRun->getKey(),
|
|
]);
|
|
|
|
if ($isNewFinding) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_NEW,
|
|
'reopened_at' => null,
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'acknowledged_at' => null,
|
|
'acknowledged_by_user_id' => null,
|
|
'first_seen_at' => $observedAt,
|
|
'last_seen_at' => $observedAt,
|
|
'times_seen' => 1,
|
|
]);
|
|
|
|
$createdCount++;
|
|
} elseif (Finding::isTerminalStatus($finding->status)) {
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => now(),
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
]);
|
|
|
|
$reopenedCount++;
|
|
} else {
|
|
$unchangedCount++;
|
|
}
|
|
|
|
$finding->save();
|
|
$processedCount++;
|
|
}
|
|
|
|
return [
|
|
'processed_count' => $processedCount,
|
|
'created_count' => $createdCount,
|
|
'reopened_count' => $reopenedCount,
|
|
'unchanged_count' => $unchangedCount,
|
|
'seen_fingerprints' => array_values(array_unique($seenFingerprints)),
|
|
];
|
|
}
|
|
|
|
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
|
{
|
|
if ($finding->first_seen_at === null) {
|
|
$finding->first_seen_at = $observedAt;
|
|
}
|
|
|
|
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
|
$finding->last_seen_at = $observedAt;
|
|
}
|
|
|
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
|
|
|
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
|
|
$finding->times_seen = max(0, $timesSeen) + 1;
|
|
} elseif ($timesSeen < 1) {
|
|
$finding->times_seen = 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{severity: string}> $driftResults
|
|
* @return array<string, int>
|
|
*/
|
|
private function countBySeverity(array $driftResults): array
|
|
{
|
|
$counts = [];
|
|
|
|
foreach ($driftResults as $item) {
|
|
$severity = $item['severity'];
|
|
$counts[$severity] = ($counts[$severity] ?? 0) + 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array
|
|
{
|
|
try {
|
|
$mapping = $settingsResolver->resolveValue(
|
|
workspace: $workspace,
|
|
domain: 'baseline',
|
|
key: 'severity_mapping',
|
|
);
|
|
} catch (\InvalidArgumentException) {
|
|
// Settings keys are registry-backed; if this key is missing (e.g. during rollout),
|
|
// fall back to built-in defaults rather than failing the entire compare run.
|
|
return [];
|
|
}
|
|
|
|
return is_array($mapping) ? $mapping : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $severityMapping
|
|
*/
|
|
private function severityForChangeType(array $severityMapping, string $changeType): string
|
|
{
|
|
$severity = $severityMapping[$changeType] ?? null;
|
|
|
|
if (! is_string($severity) || $severity === '') {
|
|
return match ($changeType) {
|
|
'missing_policy' => Finding::SEVERITY_HIGH,
|
|
'different_version' => Finding::SEVERITY_MEDIUM,
|
|
default => Finding::SEVERITY_LOW,
|
|
};
|
|
}
|
|
|
|
return $severity;
|
|
}
|
|
|
|
private function auditStarted(
|
|
AuditLogger $auditLogger,
|
|
Tenant $tenant,
|
|
BaselineProfile $profile,
|
|
?User $initiator,
|
|
): void {
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'baseline.compare.started',
|
|
context: [
|
|
'metadata' => [
|
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'baseline_profile_name' => (string) $profile->name,
|
|
],
|
|
],
|
|
actorId: $initiator?->id,
|
|
actorEmail: $initiator?->email,
|
|
actorName: $initiator?->name,
|
|
resourceType: 'baseline_profile',
|
|
resourceId: (string) $profile->getKey(),
|
|
);
|
|
}
|
|
|
|
private function auditCompleted(
|
|
AuditLogger $auditLogger,
|
|
Tenant $tenant,
|
|
BaselineProfile $profile,
|
|
?User $initiator,
|
|
array $summaryCounts,
|
|
): void {
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'baseline.compare.completed',
|
|
context: [
|
|
'metadata' => [
|
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'baseline_profile_name' => (string) $profile->name,
|
|
'findings_total' => $summaryCounts['total'] ?? 0,
|
|
'high' => $summaryCounts['high'] ?? 0,
|
|
'medium' => $summaryCounts['medium'] ?? 0,
|
|
'low' => $summaryCounts['low'] ?? 0,
|
|
],
|
|
],
|
|
actorId: $initiator?->id,
|
|
actorEmail: $initiator?->email,
|
|
actorName: $initiator?->name,
|
|
resourceType: 'operation_run',
|
|
resourceId: (string) $this->operationRun->getKey(),
|
|
);
|
|
}
|
|
}
|