'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); });