whereKey($this->workspaceId)->first(); if (! $workspace instanceof Workspace) { return; } $operationRun = $this->resolveOperationRun($workspace, $operationRuns); if (! $operationRun instanceof OperationRun) { return; } if ($operationRun->status === OperationRunStatus::Completed->value) { return; } $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Running->value, outcome: OperationRunOutcome::Pending->value, ); $windowStart = $this->resolveWindowStart($operationRun); try { $events = [ ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), ...$this->slaDueEvents((int) $workspace->getKey(), $windowStart), ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), ...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart), ...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart), ]; $createdDeliveries = 0; foreach ($events as $event) { $createdDeliveries += $dispatchService->dispatchEvent($workspace, $event); } $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: [ 'total' => count($events), 'processed' => count($events), 'created' => $createdDeliveries, ], ); } catch (Throwable $exception) { $operationRuns->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [ [ 'code' => 'alerts.evaluate.failed', 'message' => $this->sanitizeErrorMessage($exception), ], ], ); throw $exception; } } private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun { if (is_int($this->operationRunId) && $this->operationRunId > 0) { $operationRun = OperationRun::query() ->whereKey($this->operationRunId) ->where('workspace_id', (int) $workspace->getKey()) ->where('type', 'alerts.evaluate') ->first(); if ($operationRun instanceof OperationRun) { return $operationRun; } } $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; return $operationRuns->ensureWorkspaceRunWithIdentity( workspace: $workspace, type: 'alerts.evaluate', identityInputs: [ 'slot_key' => $slotKey, ], context: [ 'trigger' => 'job', 'slot_key' => $slotKey, ], initiator: null, ); } private function resolveWindowStart(OperationRun $operationRun): CarbonImmutable { $previous = OperationRun::query() ->where('workspace_id', (int) $operationRun->workspace_id) ->whereNull('tenant_id') ->where('type', 'alerts.evaluate') ->where('status', OperationRunStatus::Completed->value) ->whereNotNull('completed_at') ->where('id', '<', (int) $operationRun->getKey()) ->orderByDesc('completed_at') ->orderByDesc('id') ->first(); if ($previous instanceof OperationRun && $previous->completed_at !== null) { return CarbonImmutable::instance($previous->completed_at); } $lookbackMinutes = max(1, (int) config('tenantpilot.alerts.evaluate_initial_lookback_minutes', 15)); return CarbonImmutable::now('UTC')->subMinutes($lookbackMinutes); } /** * @return array> */ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array { $findings = Finding::query() ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where(function ($query) use ($windowStart): void { $query ->where(function ($statusQuery) use ($windowStart): void { $statusQuery ->where('status', Finding::STATUS_NEW) ->where('created_at', '>', $windowStart); }) ->orWhere(function ($statusQuery) use ($windowStart): void { $statusQuery ->where('status', Finding::STATUS_REOPENED) ->where(function ($reopenedQuery) use ($windowStart): void { $reopenedQuery ->where('reopened_at', '>', $windowStart) ->orWhere(function ($fallbackQuery) use ($windowStart): void { $fallbackQuery ->whereNull('reopened_at') ->where('updated_at', '>', $windowStart); }); }); }); }) ->orderBy('id') ->get(); $events = []; foreach ($findings as $finding) { $events[] = [ 'event_type' => 'high_drift', 'tenant_id' => (int) $finding->tenant_id, 'severity' => (string) $finding->severity, 'fingerprint_key' => 'finding:'.(int) $finding->getKey(), 'title' => 'High drift finding detected', 'body' => sprintf( 'Finding %d was created with severity %s.', (int) $finding->getKey(), (string) $finding->severity, ), 'metadata' => [ 'finding_id' => (int) $finding->getKey(), ], ]; } return $events; } /** * @return array> */ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array { $failedRuns = OperationRun::query() ->where('workspace_id', $workspaceId) ->whereNotNull('tenant_id') ->where('type', 'drift_generate_findings') ->where('status', OperationRunStatus::Completed->value) ->where('outcome', OperationRunOutcome::Failed->value) ->where('created_at', '>', $windowStart) ->orderBy('id') ->get(); $events = []; foreach ($failedRuns as $failedRun) { $tenantId = (int) ($failedRun->tenant_id ?? 0); if ($tenantId <= 0) { continue; } $events[] = [ 'event_type' => 'compare_failed', 'tenant_id' => $tenantId, 'severity' => 'high', 'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(), 'title' => 'Drift compare failed', 'body' => $this->firstFailureMessage($failedRun), 'metadata' => [ 'operation_run_id' => (int) $failedRun->getKey(), ], ]; } return $events; } /** * @return array> */ private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array { $now = CarbonImmutable::now('UTC'); $newlyOverdueTenantIds = Finding::query() ->where('workspace_id', $workspaceId) ->whereNotNull('tenant_id') ->whereNotNull('due_at') ->where('due_at', '>', $windowStart) ->where('due_at', '<=', $now) ->whereIn('status', Finding::openStatusesForQuery()) ->orderBy('tenant_id') ->pluck('tenant_id') ->map(static fn (mixed $value): int => (int) $value) ->filter(static fn (int $tenantId): bool => $tenantId > 0) ->unique() ->values() ->all(); if ($newlyOverdueTenantIds === []) { return []; } $severityRank = [ Finding::SEVERITY_LOW => 1, Finding::SEVERITY_MEDIUM => 2, Finding::SEVERITY_HIGH => 3, Finding::SEVERITY_CRITICAL => 4, ]; /** @var array, severity:string}> $summaryByTenant */ $summaryByTenant = []; foreach ($newlyOverdueTenantIds as $tenantId) { $summaryByTenant[$tenantId] = [ 'overdue_total' => 0, 'overdue_by_severity' => [ Finding::SEVERITY_CRITICAL => 0, Finding::SEVERITY_HIGH => 0, Finding::SEVERITY_MEDIUM => 0, Finding::SEVERITY_LOW => 0, ], 'severity' => Finding::SEVERITY_LOW, ]; } $overdueFindings = Finding::query() ->where('workspace_id', $workspaceId) ->whereIn('tenant_id', $newlyOverdueTenantIds) ->whereNotNull('due_at') ->where('due_at', '<=', $now) ->whereIn('status', Finding::openStatusesForQuery()) ->orderBy('tenant_id') ->orderBy('id') ->get(['tenant_id', 'severity']); foreach ($overdueFindings as $finding) { $tenantId = (int) ($finding->tenant_id ?? 0); if (! isset($summaryByTenant[$tenantId])) { continue; } $severity = strtolower(trim((string) $finding->severity)); if (! array_key_exists($severity, $severityRank)) { $severity = Finding::SEVERITY_HIGH; } $summaryByTenant[$tenantId]['overdue_total']++; $summaryByTenant[$tenantId]['overdue_by_severity'][$severity]++; $currentSeverity = $summaryByTenant[$tenantId]['severity']; if (($severityRank[$severity] ?? 0) > ($severityRank[$currentSeverity] ?? 0)) { $summaryByTenant[$tenantId]['severity'] = $severity; } } $windowFingerprint = $windowStart->setTimezone('UTC')->format('Uu'); $events = []; foreach ($newlyOverdueTenantIds as $tenantId) { $summary = $summaryByTenant[$tenantId] ?? null; if (! is_array($summary) || (int) ($summary['overdue_total'] ?? 0) <= 0) { continue; } /** @var array $counts */ $counts = $summary['overdue_by_severity']; $events[] = [ 'event_type' => AlertRule::EVENT_SLA_DUE, 'tenant_id' => $tenantId, 'severity' => (string) ($summary['severity'] ?? Finding::SEVERITY_HIGH), 'fingerprint_key' => sprintf('sla_due:tenant:%d:window:%s', $tenantId, $windowFingerprint), 'title' => 'SLA due findings detected', 'body' => sprintf( '%d open finding(s) are overdue (critical: %d, high: %d, medium: %d, low: %d).', (int) $summary['overdue_total'], (int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0), (int) ($counts[Finding::SEVERITY_HIGH] ?? 0), (int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0), (int) ($counts[Finding::SEVERITY_LOW] ?? 0), ), 'metadata' => [ 'overdue_total' => (int) $summary['overdue_total'], 'overdue_by_severity' => [ Finding::SEVERITY_CRITICAL => (int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0), Finding::SEVERITY_HIGH => (int) ($counts[Finding::SEVERITY_HIGH] ?? 0), Finding::SEVERITY_MEDIUM => (int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0), Finding::SEVERITY_LOW => (int) ($counts[Finding::SEVERITY_LOW] ?? 0), ], ], ]; } return $events; } private function firstFailureMessage(OperationRun $run): string { $failures = is_array($run->failure_summary) ? $run->failure_summary : []; foreach ($failures as $failure) { if (! is_array($failure)) { continue; } $message = trim((string) ($failure['message'] ?? '')); if ($message !== '') { return $message; } } return 'A drift compare operation run failed.'; } private function sanitizeErrorMessage(Throwable $exception): string { $message = trim($exception->getMessage()); if ($message === '') { return 'Unexpected alert evaluation error.'; } $message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message; return mb_substr($message, 0, 500); } /** * @return array> */ private function permissionMissingEvents(int $workspaceId, CarbonImmutable $windowStart): array { $findings = Finding::query() ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where('updated_at', '>', $windowStart) ->orderBy('id') ->get(); $events = []; foreach ($findings as $finding) { $events[] = [ 'event_type' => AlertRule::EVENT_PERMISSION_MISSING, 'tenant_id' => (int) $finding->tenant_id, 'severity' => (string) $finding->severity, 'fingerprint_key' => 'finding:'.(int) $finding->getKey(), 'title' => 'Missing permission detected', 'body' => sprintf( 'Permission "%s" is missing for tenant %d (severity: %s).', (string) ($finding->evidence_jsonb['permission_key'] ?? $finding->subject_external_id ?? 'unknown'), (int) $finding->tenant_id, (string) $finding->severity, ), 'metadata' => [ 'finding_id' => (int) $finding->getKey(), 'permission_key' => (string) ($finding->evidence_jsonb['permission_key'] ?? ''), ], ]; } return $events; } /** * @return array> */ private function entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): array { $findings = Finding::query() ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where('updated_at', '>', $windowStart) ->orderBy('id') ->get(); $events = []; foreach ($findings as $finding) { $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; $events[] = [ 'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH, 'tenant_id' => (int) $finding->tenant_id, 'severity' => (string) $finding->severity, 'fingerprint_key' => 'finding:'.(int) $finding->getKey(), 'title' => 'High-privilege Entra admin role detected', 'body' => sprintf( 'Role "%s" assigned to %s (severity: %s).', (string) ($evidence['role_display_name'] ?? 'unknown'), (string) ($evidence['principal_display_name'] ?? $finding->subject_external_id ?? 'unknown'), (string) $finding->severity, ), 'metadata' => [ 'finding_id' => (int) $finding->getKey(), 'role_display_name' => (string) ($evidence['role_display_name'] ?? ''), 'principal_display_name' => (string) ($evidence['principal_display_name'] ?? ''), ], ]; } return $events; } }