## Summary Automated scanning of Entra ID directory roles to surface high-privilege role assignments as trackable findings with alerting support. ## What's included ### Core Services - **EntraAdminRolesReportService** — Fetches role definitions + assignments via Graph API, builds payload with fingerprint deduplication - **EntraAdminRolesFindingGenerator** — Creates/resolves/reopens findings based on high-privilege role catalog - **HighPrivilegeRoleCatalog** — Curated list of high-privilege Entra roles (Global Admin, Privileged Auth Admin, etc.) - **ScanEntraAdminRolesJob** — Queued job orchestrating scan → report → findings → alerts pipeline ### UI - **AdminRolesSummaryWidget** — Tenant dashboard card showing last scan time, high-privilege assignment count, scan trigger button - RBAC-gated: `ENTRA_ROLES_VIEW` for viewing, `ENTRA_ROLES_MANAGE` for scan trigger ### Infrastructure - Graph contracts for `entraRoleDefinitions` + `entraRoleAssignments` - `config/entra_permissions.php` — Entra permission registry - `StoredReport.fingerprint` migration (deduplication support) - `OperationCatalog` label + duration for `entra.admin_roles.scan` - Artisan command `entra:scan-admin-roles` for CLI/scheduled use ### Global UX improvement - **SummaryCountsNormalizer**: Zero values filtered, snake_case keys humanized (e.g. `report_deduped: 1` → `Report deduped: 1`). Affects all operation notifications. ## Test Coverage - **12 test files**, **79+ tests**, **307+ assertions** - Report service, finding generator, job orchestration, widget rendering, alert integration, RBAC enforcement, badge mapping ## Spec artifacts - `specs/105-entra-admin-roles-evidence-findings/tasks.md` — Full task breakdown (38 tasks, all complete) - `specs/105-entra-admin-roles-evidence-findings/checklists/requirements.md` — All items checked ## Files changed 46 files changed, 3641 insertions(+), 15 deletions(-) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #128
331 lines
12 KiB
PHP
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',
|
|
};
|
|
}
|
|
}
|