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

289 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ScanEntraAdminRolesJob;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\StoredReport;
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
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 App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRolesReportService
{
return new EntraAdminRolesReportService(
$graphClient,
new HighPrivilegeRoleCatalog,
app(MicrosoftGraphOptionsResolver::class),
);
}
function scanJobRoleDefs(): array
{
return [
[
'id' => 'def-ga',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
],
];
}
function scanJobAssignments(): array
{
return [
[
'id' => 'assign-1',
'roleDefinitionId' => 'def-ga',
'principalId' => 'user-aaa',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Alice Admin',
],
],
];
}
function scanJobGraphMock(bool $failDefinitions = false, bool $failAssignments = false): GraphClientInterface
{
return new class(scanJobRoleDefs(), scanJobAssignments(), $failDefinitions, $failAssignments) implements GraphClientInterface
{
public function __construct(
private readonly array $roleDefs,
private readonly array $assignments,
private readonly bool $failDefs,
private readonly bool $failAssigns,
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return match ($policyType) {
'entraRoleDefinitions' => new GraphResponse(
success: ! $this->failDefs,
data: $this->failDefs ? [] : $this->roleDefs,
status: $this->failDefs ? 403 : 200,
errors: $this->failDefs ? ['Forbidden'] : [],
),
'entraRoleAssignments' => new GraphResponse(
success: ! $this->failAssigns,
data: $this->failAssigns ? [] : $this->assignments,
status: $this->failAssigns ? 403 : 200,
errors: $this->failAssigns ? ['Forbidden'] : [],
),
default => new GraphResponse(success: false, status: 404),
};
}
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);
}
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
it('successful run creates OperationRun and records success with counts', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$mock = scanJobGraphMock();
$job = new ScanEntraAdminRolesJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
);
$job->handle(
buildScanReportService($mock),
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
app(\App\Services\OperationRunService::class),
);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'entra.admin_roles.scan')
->first();
expect($run)->not->toBeNull()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->summary_counts)->toBeArray()
->and($run->summary_counts['report_created'])->toBe(1)
->and($run->summary_counts['findings_created'])->toBeGreaterThanOrEqual(1);
// Verify report and findings also exist
$report = StoredReport::query()
->where('tenant_id', $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->first();
expect($report)->not->toBeNull();
$findingsCount = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($findingsCount)->toBeGreaterThanOrEqual(1);
});
it('skips tenant without active provider connection', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
// Deliberately NOT creating a provider connection
$job = new ScanEntraAdminRolesJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
);
$job->handle(
buildScanReportService(scanJobGraphMock()),
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
app(\App\Services\OperationRunService::class),
);
$runCount = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'entra.admin_roles.scan')
->count();
expect($runCount)->toBe(0);
$reportCount = StoredReport::query()
->where('tenant_id', $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($reportCount)->toBe(0);
});
it('Graph failure marks OperationRun as failed and re-throws', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$mock = scanJobGraphMock(failDefinitions: true);
$job = new ScanEntraAdminRolesJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
);
$thrown = false;
try {
$job->handle(
buildScanReportService($mock),
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
app(\App\Services\OperationRunService::class),
);
} catch (RuntimeException) {
$thrown = true;
}
expect($thrown)->toBeTrue();
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'entra.admin_roles.scan')
->first();
expect($run)->not->toBeNull()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
->and($run->failure_summary)->toBeArray()
->and($run->failure_summary[0]['code'])->toBe('entra.admin_roles.scan.failed');
});
it('finding generator runs even when report is deduped', function (): void {
[$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant);
$mock = scanJobGraphMock();
$reportService = buildScanReportService($mock);
$findingGenerator = new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
$runService = app(\App\Services\OperationRunService::class);
// Run 1: creates report
$job1 = new ScanEntraAdminRolesJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
);
$job1->handle($reportService, $findingGenerator, $runService);
$reportCount = StoredReport::query()
->where('tenant_id', $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($reportCount)->toBe(1);
// Mark existing OperationRun as completed so a new one can be created
OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'entra.admin_roles.scan')
->update(['status' => OperationRunStatus::Completed->value]);
// Run 2: report deduped, but findings still processed
$job2 = new ScanEntraAdminRolesJob(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
);
$job2->handle($reportService, $findingGenerator, $runService);
// Still only 1 report (deduped)
$reportCount2 = StoredReport::query()
->where('tenant_id', $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->count();
expect($reportCount2)->toBe(1);
// But the run completed successfully with deduped count
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'entra.admin_roles.scan')
->where('outcome', OperationRunOutcome::Succeeded->value)
->latest()
->first();
expect($latestRun)->not->toBeNull()
->and($latestRun->summary_counts['report_deduped'])->toBe(1);
});