## 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
351 lines
13 KiB
PHP
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']);
|
|
});
|