TenantAtlas/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
Ahmed Darrazi 6b381e9517 feat: spec 105 — Entra Admin Roles scan, reports, findings, widget + summary UX improvement
- Entra admin roles scan job (ScanEntraAdminRolesJob)
- Report service with fingerprint deduplication
- Finding generator with high-privilege role catalog
- Admin roles summary widget on tenant view page
- Alert integration for entra.admin_roles findings
- Graph contracts for roleDefinitions + roleAssignments
- Entra permissions registry (config/entra_permissions.php)
- StoredReport fingerprint migration
- OperationCatalog label + duration for entra.admin_roles.scan
- SummaryCountsNormalizer: filter zeros, humanize keys globally
- 11 new test files (71+ tests, 286+ assertions)
- Spec + tasks + checklist updates
2026-02-22 03:35:46 +01:00

331 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\EntraAdminRoles;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
final class EntraAdminRolesFindingGenerator
{
private const int GA_THRESHOLD = 5; // TODO: move to settings when configurable thresholds are implemented
/** @var array<int, array<string, mixed>> */
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<int, array<string, mixed>>
*/
public function getAlertEvents(): array
{
return $this->alertEvents;
}
/**
* @return array<string, array<string, mixed>>
*/
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<string, mixed>
*/
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',
};
}
}