TenantAtlas/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
ahmido 6a15fe978a feat: Spec 105 — Entra Admin Roles Evidence + Findings (#128)
## 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
2026-02-22 02:37:36 +00:00

490 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AlertRule;
use App\Models\Finding;
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function gaRoleDef(): array
{
return [
'id' => 'def-ga',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
];
}
function secAdminRoleDef(): array
{
return [
'id' => 'def-secadmin',
'displayName' => 'Security Administrator',
'templateId' => '194ae4cb-b126-40b2-bd5b-6091b380977d',
'isBuiltIn' => true,
];
}
function readerRoleDef(): array
{
return [
'id' => 'def-reader',
'displayName' => 'Directory Readers',
'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601',
'isBuiltIn' => true,
];
}
function makeAssignment(string $id, string $roleDefId, string $principalId, string $odataType = '#microsoft.graph.user', string $displayName = 'User', string $scopeId = '/'): array
{
return [
'id' => $id,
'roleDefinitionId' => $roleDefId,
'principalId' => $principalId,
'directoryScopeId' => $scopeId,
'principal' => [
'@odata.type' => $odataType,
'displayName' => $displayName,
],
];
}
function buildPayload(array $roleDefinitions, array $roleAssignments, ?string $measuredAt = null): array
{
return [
'provider_key' => 'microsoft',
'domain' => 'entra.admin_roles',
'measured_at' => $measuredAt ?? CarbonImmutable::now('UTC')->toIso8601String(),
'role_definitions' => $roleDefinitions,
'role_assignments' => $roleAssignments,
'totals' => [
'roles_total' => count($roleDefinitions),
'assignments_total' => count($roleAssignments),
'high_privilege_assignments' => 0,
],
'high_privilege' => [],
];
}
function makeGenerator(): EntraAdminRolesFindingGenerator
{
return new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
it('creates findings for high-privilege assignments with correct attributes', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef(), secAdminRoleDef()],
[
makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin'),
makeAssignment('a2', 'def-secadmin', 'user-2', '#microsoft.graph.user', 'Bob SecAdmin'),
],
);
$result = makeGenerator()->generate($tenant, $payload);
expect($result->created)->toBe(2)
->and($result->resolved)->toBe(0)
->and($result->reopened)->toBe(0)
->and($result->unchanged)->toBe(0);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->get();
expect($findings)->toHaveCount(2);
$gaFinding = $findings->firstWhere('severity', Finding::SEVERITY_CRITICAL);
expect($gaFinding)->not->toBeNull()
->and($gaFinding->source)->toBe('entra.admin_roles')
->and($gaFinding->subject_type)->toBe('role_assignment')
->and($gaFinding->subject_external_id)->toBe('user-1:def-ga')
->and($gaFinding->status)->toBe(Finding::STATUS_NEW);
$secFinding = $findings->firstWhere('severity', Finding::SEVERITY_HIGH);
expect($secFinding)->not->toBeNull()
->and($secFinding->subject_external_id)->toBe('user-2:def-secadmin');
});
it('maps severity: GA is critical, others are high', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef(), secAdminRoleDef()],
[
makeAssignment('a1', 'def-ga', 'user-1'),
makeAssignment('a2', 'def-secadmin', 'user-2'),
],
);
makeGenerator()->generate($tenant, $payload);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->orderBy('severity')
->get();
$severities = $findings->pluck('severity')->toArray();
expect($severities)->toContain(Finding::SEVERITY_CRITICAL)
->and($severities)->toContain(Finding::SEVERITY_HIGH);
});
it('is idempotent — same data produces no duplicates', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
$generator = makeGenerator();
$first = $generator->generate($tenant, $payload);
$second = $generator->generate($tenant, $payload);
expect($first->created)->toBe(1)
->and($second->created)->toBe(0)
->and($second->unchanged)->toBe(1);
$count = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($count)->toBe(1);
});
it('auto-resolves when assignment is removed', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
// First scan: user-1 has GA
$payload1 = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
$generator->generate($tenant, $payload1);
// Second scan: user-1 GA removed
$payload2 = buildPayload([gaRoleDef()], []);
$result2 = $generator->generate($tenant, $payload2);
expect($result2->resolved)->toBeGreaterThanOrEqual(1);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->where('subject_external_id', 'user-1:def-ga')
->first();
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->resolved_reason)->toBe('role_assignment_removed');
});
it('re-opens resolved finding when role is re-assigned', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
// Scan 1: create
$payload1 = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice')],
);
$generator->generate($tenant, $payload1);
// Scan 2: remove → auto-resolve
$payload2 = buildPayload([gaRoleDef()], []);
$generator->generate($tenant, $payload2);
// Scan 3: re-assign → re-open
$payload3 = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Reactivated')],
);
$result3 = $generator->generate($tenant, $payload3);
expect($result3->reopened)->toBe(1);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->where('subject_external_id', 'user-1:def-ga')
->first();
expect($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->resolved_at)->toBeNull()
->and($finding->resolved_reason)->toBeNull()
->and($finding->evidence_jsonb['principal_display_name'])->toBe('Alice Reactivated');
});
it('creates aggregate finding when GA count exceeds threshold', function (): void {
[$user, $tenant] = createUserWithTenant();
$assignments = [];
for ($i = 1; $i <= 6; $i++) {
$assignments[] = makeAssignment("a{$i}", 'def-ga', "user-{$i}", '#microsoft.graph.user', "GA User {$i}");
}
$payload = buildPayload([gaRoleDef()], $assignments);
$result = makeGenerator()->generate($tenant, $payload);
// 6 individual + 1 aggregate = 7 findings
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->get();
expect($findings)->toHaveCount(7);
$aggregate = $findings->firstWhere('subject_external_id', 'ga_aggregate');
expect($aggregate)->not->toBeNull()
->and($aggregate->severity)->toBe(Finding::SEVERITY_HIGH)
->and($aggregate->evidence_jsonb['count'])->toBe(6)
->and($aggregate->evidence_jsonb['threshold'])->toBe(5)
->and($aggregate->evidence_jsonb['principals'])->toHaveCount(6);
});
it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
// Scan 1: 6 GAs → aggregate created
$assignments = [];
for ($i = 1; $i <= 6; $i++) {
$assignments[] = makeAssignment("a{$i}", 'def-ga', "user-{$i}");
}
$generator->generate($tenant, buildPayload([gaRoleDef()], $assignments));
// Scan 2: 5 GAs → aggregate auto-resolved
$smallerAssignments = array_slice($assignments, 0, 5);
$result2 = $generator->generate($tenant, buildPayload([gaRoleDef()], $smallerAssignments));
expect($result2->resolved)->toBeGreaterThanOrEqual(1);
$aggregate = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('subject_external_id', 'ga_aggregate')
->first();
expect($aggregate->status)->toBe(Finding::STATUS_RESOLVED)
->and($aggregate->resolved_reason)->toBe('ga_count_within_threshold');
});
it('produces alert events for new and re-opened findings with severity >= high', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
$generator->generate($tenant, $payload);
$events = $generator->getAlertEvents();
expect($events)->not->toBeEmpty();
$event = $events[0];
expect($event['event_type'])->toBe(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
->and($event['tenant_id'])->toBe((int) $tenant->getKey());
});
it('produces no alert events for unchanged or resolved findings', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
// Scan 1: alert events produced for new finding
$generator->generate($tenant, $payload);
// Scan 2: unchanged — no new alert events
$generator2 = makeGenerator();
$generator2->generate($tenant, $payload);
$events = $generator2->getAlertEvents();
expect($events)->toBeEmpty();
});
it('evidence contains all required fields', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin')],
);
makeGenerator()->generate($tenant, $payload);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->where('subject_external_id', 'user-1:def-ga')
->first();
$evidence = $finding->evidence_jsonb;
expect($evidence)->toHaveKeys([
'role_display_name',
'principal_display_name',
'principal_type',
'principal_id',
'role_definition_id',
'role_template_id',
'directory_scope_id',
'is_built_in',
'measured_at',
])
->and($evidence['role_display_name'])->toBe('Global Administrator')
->and($evidence['principal_display_name'])->toBe('Alice Admin')
->and($evidence['principal_type'])->toBe('user')
->and($evidence['principal_id'])->toBe('user-1')
->and($evidence['role_definition_id'])->toBe('def-ga')
->and($evidence['is_built_in'])->toBeTrue();
});
it('handles all principal types correctly', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef()],
[
makeAssignment('a1', 'def-ga', 'p-user', '#microsoft.graph.user', 'User'),
makeAssignment('a2', 'def-ga', 'p-group', '#microsoft.graph.group', 'Group'),
makeAssignment('a3', 'def-ga', 'p-sp', '#microsoft.graph.servicePrincipal', 'SP'),
],
);
makeGenerator()->generate($tenant, $payload);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->get();
$types = $findings->pluck('evidence_jsonb.principal_type')->sort()->values()->toArray();
expect($types)->toContain('user')
->toContain('group')
->toContain('servicePrincipal');
});
it('subject_type and subject_external_id set on every finding', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
makeGenerator()->generate($tenant, $payload);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->first();
expect($finding->subject_type)->toBe('role_assignment')
->and($finding->subject_external_id)->toBe('user-1:def-ga');
});
it('auto-resolve applies to acknowledged findings too', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = makeGenerator();
// Scan 1: create
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1')],
);
$generator->generate($tenant, $payload);
// Acknowledge the finding
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('subject_external_id', 'user-1:def-ga')
->first();
$finding->acknowledge($user);
expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
// Scan 2: remove → should auto-resolve even though acknowledged
$payload2 = buildPayload([gaRoleDef()], []);
$result = $generator->generate($tenant, $payload2);
expect($result->resolved)->toBeGreaterThanOrEqual(1);
$resolved = $finding->fresh();
expect($resolved->status)->toBe(Finding::STATUS_RESOLVED)
->and($resolved->resolved_reason)->toBe('role_assignment_removed');
});
it('scoped assignments do not downgrade severity', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[gaRoleDef()],
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice', '/administrativeUnits/au-123')],
);
makeGenerator()->generate($tenant, $payload);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->first();
expect($finding->severity)->toBe(Finding::SEVERITY_CRITICAL)
->and($finding->evidence_jsonb['directory_scope_id'])->toBe('/administrativeUnits/au-123');
});
it('does not create findings for non-high-privilege roles', function (): void {
[$user, $tenant] = createUserWithTenant();
$payload = buildPayload(
[readerRoleDef()],
[makeAssignment('a1', 'def-reader', 'user-1')],
);
$result = makeGenerator()->generate($tenant, $payload);
expect($result->created)->toBe(0);
$count = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($count)->toBe(0);
});