TenantAtlas/tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.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

351 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\StoredReport;
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sampleRoleDefinitions(): array
{
return [
[
'id' => 'def-ga-001',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
],
[
'id' => 'def-ua-002',
'displayName' => 'User Administrator',
'templateId' => 'fe930be7-5e62-47db-91af-98c3a49a38b1',
'isBuiltIn' => true,
],
[
'id' => 'def-reader-003',
'displayName' => 'Directory Readers',
'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601',
'isBuiltIn' => true,
],
];
}
function sampleRoleAssignments(): array
{
return [
[
'id' => 'assign-1',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'user-aaa',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Alice Admin',
],
],
[
'id' => 'assign-2',
'roleDefinitionId' => 'def-ua-002',
'principalId' => 'user-bbb',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Bob Useradmin',
],
],
[
'id' => 'assign-3',
'roleDefinitionId' => 'def-reader-003',
'principalId' => 'group-ccc',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.group',
'displayName' => 'Readers Group',
],
],
];
}
/**
* Build a mock GraphClientInterface that returns specific data for entra types.
*/
function buildGraphMock(
array $roleDefinitions,
array $roleAssignments,
bool $failDefinitions = false,
bool $failAssignments = false,
): GraphClientInterface {
return new class($roleDefinitions, $roleAssignments, $failDefinitions, $failAssignments) implements GraphClientInterface
{
public function __construct(
private readonly array $roleDefinitions,
private readonly array $roleAssignments,
private readonly bool $failDefinitions,
private readonly bool $failAssignments,
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return match ($policyType) {
'entraRoleDefinitions' => new GraphResponse(
success: ! $this->failDefinitions,
data: $this->failDefinitions ? [] : $this->roleDefinitions,
status: $this->failDefinitions ? 403 : 200,
errors: $this->failDefinitions ? ['Forbidden'] : [],
),
'entraRoleAssignments' => new GraphResponse(
success: ! $this->failAssignments,
data: $this->failAssignments ? [] : $this->roleAssignments,
status: $this->failAssignments ? 403 : 200,
errors: $this->failAssignments ? ['Forbidden'] : [],
),
default => new GraphResponse(success: false, status: 404, errors: ['Unknown type']),
};
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
};
}
function buildReportService(GraphClientInterface $graphMock): EntraAdminRolesReportService
{
return new EntraAdminRolesReportService(
graphClient: $graphMock,
catalog: new HighPrivilegeRoleCatalog,
graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class),
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
it('creates a new report with correct attributes', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
$result = $service->generate($tenant);
expect($result->created)->toBeTrue()
->and($result->storedReportId)->toBeInt()
->and($result->fingerprint)->toBeString()->toHaveLength(64)
->and($result->payload)->toBeArray();
$report = StoredReport::find($result->storedReportId);
expect($report)->not->toBeNull()
->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->and($report->tenant_id)->toBe((int) $tenant->getKey())
->and($report->fingerprint)->toBe($result->fingerprint)
->and($report->previous_fingerprint)->toBeNull();
});
it('deduplicates when fingerprint matches latest report', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
$first = $service->generate($tenant);
expect($first->created)->toBeTrue();
$second = $service->generate($tenant);
expect($second->created)->toBeFalse()
->and($second->storedReportId)->toBe($first->storedReportId)
->and($second->fingerprint)->toBe($first->fingerprint);
$count = StoredReport::query()
->where('tenant_id', $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($count)->toBe(1);
});
it('chains previous_fingerprint when data changes', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$originalAssignments = sampleRoleAssignments();
$service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $originalAssignments));
$first = $service1->generate($tenant);
// Add a new assignment to produce a different fingerprint
$changedAssignments = array_merge($originalAssignments, [[
'id' => 'assign-new',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'user-zzz',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Zach New',
],
]]);
$service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $changedAssignments));
$second = $service2->generate($tenant);
expect($second->created)->toBeTrue()
->and($second->fingerprint)->not->toBe($first->fingerprint)
->and(StoredReport::find($second->storedReportId)->previous_fingerprint)->toBe($first->fingerprint);
});
it('throws when role definitions fetch fails', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock([], [], failDefinitions: true));
$service->generate($tenant);
})->throws(RuntimeException::class, 'Failed to fetch Entra role definitions');
it('throws when role assignments fetch fails', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), [], failAssignments: true));
$service->generate($tenant);
})->throws(RuntimeException::class, 'Failed to fetch Entra role assignments');
it('produces expected payload structure', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
$result = $service->generate($tenant);
$payload = $result->payload;
expect($payload)
->toHaveKeys(['provider_key', 'domain', 'measured_at', 'role_definitions', 'role_assignments', 'totals', 'high_privilege'])
->and($payload['provider_key'])->toBe('microsoft')
->and($payload['domain'])->toBe('entra.admin_roles')
->and($payload['role_definitions'])->toHaveCount(3)
->and($payload['role_assignments'])->toHaveCount(3)
->and($payload['totals']['roles_total'])->toBe(3)
->and($payload['totals']['assignments_total'])->toBe(3)
// Only GA is in the high-privilege catalog
->and($payload['totals']['high_privilege_assignments'])->toBe(1)
->and($payload['high_privilege'])->toHaveCount(1);
// Verify first high-privilege entry shape
$ga = collect($payload['high_privilege'])->firstWhere('role_template_id', '62e90394-69f5-4237-9190-012177145e10');
expect($ga)->not->toBeNull()
->and($ga['role_display_name'])->toBe('Global Administrator')
->and($ga['principal_id'])->toBe('user-aaa')
->and($ga['principal_type'])->toBe('user')
->and($ga['severity'])->toBe('critical');
});
it('computes deterministic fingerprint regardless of assignment order', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$assignments = sampleRoleAssignments();
$reversedAssignments = array_reverse($assignments);
$service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments));
$result1 = $service1->generate($tenant);
// Delete the report to allow re-creation
StoredReport::query()->where('id', $result1->storedReportId)->delete();
$service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $reversedAssignments));
$result2 = $service2->generate($tenant);
expect($result2->fingerprint)->toBe($result1->fingerprint);
});
it('handles zero assignments gracefully', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), []));
$result = $service->generate($tenant);
expect($result->created)->toBeTrue()
->and($result->payload['totals']['assignments_total'])->toBe(0)
->and($result->payload['totals']['high_privilege_assignments'])->toBe(0)
->and($result->payload['high_privilege'])->toBeEmpty();
});
it('resolves principal types correctly', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$assignments = [
[
'id' => 'a1',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'p-user',
'directoryScopeId' => '/',
'principal' => ['@odata.type' => '#microsoft.graph.user', 'displayName' => 'User'],
],
[
'id' => 'a2',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'p-group',
'directoryScopeId' => '/',
'principal' => ['@odata.type' => '#microsoft.graph.group', 'displayName' => 'Group'],
],
[
'id' => 'a3',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'p-sp',
'directoryScopeId' => '/',
'principal' => ['@odata.type' => '#microsoft.graph.servicePrincipal', 'displayName' => 'SP'],
],
[
'id' => 'a4',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'p-unknown',
'directoryScopeId' => '/',
'principal' => ['displayName' => 'NoType'],
],
];
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments));
$result = $service->generate($tenant);
$types = collect($result->payload['high_privilege'])->pluck('principal_type')->toArray();
expect($types)->toBe(['user', 'group', 'servicePrincipal', 'unknown']);
});