## Why `dev` full suite was hard-failing with `PHP Fatal error: Cannot redeclare function makeAssignment()` due to two Pest files defining the same global helper. Additionally, Ops-UX tests were out of sync with the new summary rendering + new whitelisted keys. ## What changed - Renamed the Entra Admin Roles test helper to `makeEntraAssignment()` to avoid global collision. - Updated Ops-UX canonical key list in `specs/055-ops-ux-rollout/spec.md` to include: - `report_created`, `report_deduped`, `alert_events_produced` - Updated `SummaryCountsWhitelistTest` to match the new summary rendering: - no `Summary:` prefix - humanized keys (`Total`, `Processed`) ## Verification - `vendor/bin/sail artisan test --compact`: - **1572 passed**, **7 skipped** (8044 assertions) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #130
490 lines
16 KiB
PHP
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 makeEntraAssignment(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()],
|
|
[
|
|
makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin'),
|
|
makeEntraAssignment('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()],
|
|
[
|
|
makeEntraAssignment('a1', 'def-ga', 'user-1'),
|
|
makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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[] = makeEntraAssignment("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[] = makeEntraAssignment("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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[
|
|
makeEntraAssignment('a1', 'def-ga', 'p-user', '#microsoft.graph.user', 'User'),
|
|
makeEntraAssignment('a2', 'def-ga', 'p-group', '#microsoft.graph.group', 'Group'),
|
|
makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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()],
|
|
[makeEntraAssignment('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);
|
|
});
|