> */ private array $alertEvents = []; public function __construct( private readonly HighPrivilegeRoleCatalog $catalog, ) {} /** * Generate/upsert/auto-resolve findings based on report payload data. */ public function generate( Tenant $tenant, array $reportPayload, ?OperationRun $operationRun = null, ): EntraAdminRolesFindingResult { $this->alertEvents = []; $created = 0; $resolved = 0; $reopened = 0; $unchanged = 0; $roleAssignments = is_array($reportPayload['role_assignments'] ?? null) ? $reportPayload['role_assignments'] : []; $roleDefinitions = is_array($reportPayload['role_definitions'] ?? null) ? $reportPayload['role_definitions'] : []; $measuredAt = (string) ($reportPayload['measured_at'] ?? CarbonImmutable::now('UTC')->toIso8601String()); $roleDefMap = $this->buildRoleDefMap($roleDefinitions); $currentFingerprints = []; $gaCount = 0; $gaPrincipals = []; foreach ($roleAssignments as $assignment) { $roleDefId = (string) ($assignment['roleDefinitionId'] ?? ''); $roleDef = $roleDefMap[$roleDefId] ?? null; $templateId = (string) ($roleDef['templateId'] ?? $roleDefId); $displayName = $roleDef !== null ? (string) ($roleDef['displayName'] ?? '') : null; $severity = $this->catalog->classify($templateId, $displayName); if ($severity === null) { continue; } $principalId = (string) ($assignment['principalId'] ?? ''); $scopeId = (string) ($assignment['directoryScopeId'] ?? '/'); $principal = $assignment['principal'] ?? []; $fingerprint = $this->individualFingerprint($tenant, $templateId, $principalId, $scopeId); $currentFingerprints[] = $fingerprint; $evidence = $this->buildEvidence($assignment, $roleDef, $principal, $severity, $measuredAt); $result = $this->upsertFinding($tenant, $fingerprint, $severity, $evidence, $principalId, $roleDefId, $operationRun); match ($result) { 'created' => $created++, 'reopened' => $reopened++, default => $unchanged++, }; if (in_array($result, ['created', 'reopened'], true) && in_array($severity, [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL], true)) { $this->produceAlertEvent($tenant, $fingerprint, $evidence); } if ($this->catalog->isGlobalAdministrator($templateId, $displayName)) { $gaCount++; $gaPrincipals[] = [ 'display_name' => (string) ($principal['displayName'] ?? 'Unknown'), 'type' => $this->resolvePrincipalType($principal), 'id' => $principalId, ]; } } // Aggregate "Too many Global Admins" finding $resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $operationRun, $created, $reopened); // Auto-resolve stale findings $resolved += $this->resolveStaleFindings($tenant, $currentFingerprints); return new EntraAdminRolesFindingResult( created: $created, resolved: $resolved, reopened: $reopened, unchanged: $unchanged, alertEventsProduced: count($this->alertEvents), ); } /** * @return array> */ public function getAlertEvents(): array { return $this->alertEvents; } /** * @return array> */ private function buildRoleDefMap(array $roleDefinitions): array { $map = []; foreach ($roleDefinitions as $def) { $id = (string) ($def['id'] ?? ''); if ($id !== '') { $map[$id] = $def; } } return $map; } private function upsertFinding( Tenant $tenant, string $fingerprint, string $severity, array $evidence, string $principalId, string $roleDefId, ?OperationRun $operationRun, ): string { $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('fingerprint', $fingerprint) ->first(); if ($existing instanceof Finding) { if ($existing->status === Finding::STATUS_RESOLVED) { $existing->reopen($evidence); return 'reopened'; } // Update evidence on existing open finding $existing->update(['evidence_jsonb' => $evidence]); return 'unchanged'; } Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES, 'source' => 'entra.admin_roles', 'scope_key' => hash('sha256', 'entra_admin_roles:'.$tenant->getKey()), 'fingerprint' => $fingerprint, 'subject_type' => 'role_assignment', 'subject_external_id' => "{$principalId}:{$roleDefId}", 'severity' => $severity, 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), ]); return 'created'; } /** * Handle aggregate "Too many Global Admins" finding. * * @return int Number of resolved findings (0 or 1) */ private function handleGaAggregate( Tenant $tenant, int $gaCount, array $gaPrincipals, array &$currentFingerprints, ?OperationRun $operationRun, int &$created, int &$reopened, ): int { $gaFingerprint = $this->gaAggregateFingerprint($tenant); $currentFingerprints[] = $gaFingerprint; $resolved = 0; if ($gaCount > self::GA_THRESHOLD) { $evidence = [ 'count' => $gaCount, 'threshold' => self::GA_THRESHOLD, 'principals' => $gaPrincipals, ]; $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('fingerprint', $gaFingerprint) ->first(); if ($existing instanceof Finding) { if ($existing->status === Finding::STATUS_RESOLVED) { $existing->reopen($evidence); $reopened++; $this->produceAlertEvent($tenant, $gaFingerprint, $evidence); } else { $existing->update(['evidence_jsonb' => $evidence]); } } else { Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES, 'source' => 'entra.admin_roles', 'scope_key' => hash('sha256', 'entra_admin_roles_ga_count:'.$tenant->getKey()), 'fingerprint' => $gaFingerprint, 'subject_type' => 'role_assignment', 'subject_external_id' => 'ga_aggregate', 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), ]); $created++; $this->produceAlertEvent($tenant, $gaFingerprint, $evidence); } } else { // Auto-resolve aggregate if threshold met $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('fingerprint', $gaFingerprint) ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) ->first(); if ($existing instanceof Finding) { $existing->resolve('ga_count_within_threshold'); $resolved++; } } return $resolved; } /** * Resolve open findings whose fingerprint is not in the current scan. */ private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints): int { $staleFindings = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) ->whereNotIn('fingerprint', $currentFingerprints) ->get(); $resolved = 0; foreach ($staleFindings as $finding) { $finding->resolve('role_assignment_removed'); $resolved++; } return $resolved; } private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void { $this->alertEvents[] = [ 'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH, 'tenant_id' => (int) $tenant->getKey(), 'severity' => $evidence['severity'] ?? Finding::SEVERITY_HIGH, 'fingerprint_key' => 'entra_admin_role:'.$fingerprint, 'title' => 'High-privilege Entra admin role detected', 'body' => sprintf( 'Role "%s" assigned to %s on tenant %s.', $evidence['role_display_name'] ?? 'Unknown', $evidence['principal_display_name'] ?? 'Unknown', $tenant->name ?? (string) $tenant->getKey(), ), 'metadata' => $evidence, ]; } /** * @return array */ private function buildEvidence(array $assignment, ?array $roleDef, array $principal, string $severity, string $measuredAt): array { return [ 'role_display_name' => (string) ($roleDef['displayName'] ?? 'Unknown'), 'principal_display_name' => (string) ($principal['displayName'] ?? 'Unknown'), 'principal_type' => $this->resolvePrincipalType($principal), 'principal_id' => (string) ($assignment['principalId'] ?? ''), 'role_definition_id' => (string) ($assignment['roleDefinitionId'] ?? ''), 'role_template_id' => (string) ($roleDef['templateId'] ?? ''), 'directory_scope_id' => (string) ($assignment['directoryScopeId'] ?? '/'), 'is_built_in' => (bool) ($roleDef['isBuiltIn'] ?? false), 'severity' => $severity, 'measured_at' => $measuredAt, ]; } private function individualFingerprint(Tenant $tenant, string $templateId, string $principalId, string $scopeId): string { return substr(hash('sha256', "entra_admin_role:{$tenant->getKey()}:{$templateId}:{$principalId}:{$scopeId}"), 0, 64); } private function gaAggregateFingerprint(Tenant $tenant): string { return substr(hash('sha256', "entra_admin_role_ga_count:{$tenant->getKey()}"), 0, 64); } private function resolvePrincipalType(array $principal): string { $odataType = (string) ($principal['@odata.type'] ?? ''); return match (true) { str_contains($odataType, 'user') => 'user', str_contains($odataType, 'group') => 'group', str_contains($odataType, 'servicePrincipal') => 'servicePrincipal', default => 'unknown', }; } }