## 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
8.4 KiB
8.4 KiB
Data Model: Entra Admin Roles Evidence + Findings (Spec 105)
Date: 2026-02-21 | Branch: 105-entra-admin-roles-evidence-findings
Migration: Add fingerprint columns to stored_reports
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
fingerprint |
string(64) | YES | null | Content-based SHA-256 hash for deduplication |
previous_fingerprint |
string(64) | YES | null | References prior report's fingerprint for drift chain |
New indexes:
- Unique:
[tenant_id, report_type, fingerprint]— prevents duplicate reports with identical content per tenant/type - Index:
[tenant_id, report_type, created_at DESC]— optimizes "latest report per type" queries
Why nullable: Existing permission_posture reports (Spec 104) don't use fingerprinting. The column must be nullable so existing data remains valid.
No changes to findings table
The existing table already has all required columns. New values are used in existing string columns:
finding_type = 'entra_admin_roles'(new value)source = 'entra.admin_roles'(new value)subject_type = 'role_assignment'(new value)- Gravity, status, fingerprint, evidence_jsonb — all existing columns reused as-is
No changes to alert_rules table
The event_type column is a string — entra.admin_roles.high is a new value, not a schema change.
Modified Model: StoredReport
New Constants
const REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';
New Fillable / Attributes
protected $fillable = [
'workspace_id',
'tenant_id',
'report_type',
'payload',
'fingerprint', // NEW
'previous_fingerprint', // NEW
];
Modified Model: Finding
New Constants
const FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
No method changes — existing resolve(), reopen(), and fingerprint-based lookup are reused.
Modified Model: AlertRule
New Constant
const EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
Modified Enum: OperationRunType
New Case
case EntraAdminRolesScan = 'entra.admin_roles.scan';
Modified Class: Capabilities
New Constants
const ENTRA_ROLES_VIEW = 'entra_roles.view';
const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
New Class: HighPrivilegeRoleCatalog
namespace App\Services\EntraAdminRoles;
final class HighPrivilegeRoleCatalog
{
/**
* Template ID → severity mapping.
*
* @var array<string, string>
*/
private const CATALOG = [
'62e90394-69f5-4237-9190-012177145e10' => 'critical', // Global Administrator
'e8611ab8-c189-46e8-94e1-60213ab1f814' => 'high', // Privileged Role Administrator
'194ae4cb-b126-40b2-bd5b-6091b380977d' => 'high', // Security Administrator
'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' => 'high', // Conditional Access Administrator
'29232cdf-9323-42fd-ade2-1d097af3e4de' => 'high', // Exchange Administrator
'c4e39bd9-1100-46d3-8c65-fb160da0071f' => 'high', // Authentication Administrator
];
/**
* Display name fallback (case-insensitive) for roles without template_id.
*
* @var array<string, string>
*/
private const DISPLAY_NAME_FALLBACK = [
'global administrator' => 'critical',
'privileged role administrator' => 'high',
'security administrator' => 'high',
'conditional access administrator' => 'high',
'exchange administrator' => 'high',
'authentication administrator' => 'high',
];
public function classify(string $templateIdOrId, ?string $displayName): ?string;
// Returns severity ('critical'|'high') or null if not high-privilege
public function isHighPrivilege(string $templateIdOrId, ?string $displayName): bool;
public function isGlobalAdministrator(string $templateIdOrId, ?string $displayName): bool;
// Specifically checks for Global Administrator
}
New Class: EntraAdminRolesReportService
namespace App\Services\EntraAdminRoles;
final class EntraAdminRolesReportService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly HighPrivilegeRoleCatalog $catalog,
) {}
/**
* Fetch role data from Graph, build payload, create/deduplicate stored report.
*
* @return EntraAdminRolesReportResult
*/
public function generate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResult;
}
New Class: EntraAdminRolesFindingGenerator
namespace App\Services\EntraAdminRoles;
final class EntraAdminRolesFindingGenerator
{
public function __construct(
private readonly HighPrivilegeRoleCatalog $catalog,
) {}
/**
* Generate/upsert/auto-resolve findings based on report data.
*
* @param array $reportPayload The payload from the stored report
* @return EntraAdminRolesFindingResult
*/
public function generate(
Tenant $tenant,
array $reportPayload,
?OperationRun $operationRun = null,
): EntraAdminRolesFindingResult;
/**
* @return array<int, array<string, mixed>> Alert events produced during generation
*/
public function getAlertEvents(): array;
}
New Value Object: EntraAdminRolesReportResult
namespace App\Services\EntraAdminRoles;
final readonly class EntraAdminRolesReportResult
{
public function __construct(
public bool $created, // true if new report was created (not a dupe)
public ?int $storedReportId, // ID of the report (new or existing)
public string $fingerprint, // content fingerprint
public array $payload, // the full payload
) {}
}
New Value Object: EntraAdminRolesFindingResult
namespace App\Services\EntraAdminRoles;
final readonly class EntraAdminRolesFindingResult
{
public function __construct(
public int $created,
public int $resolved,
public int $reopened,
public int $unchanged,
public int $alertEventsProduced,
) {}
}
New Job: ScanEntraAdminRolesJob
namespace App\Jobs;
class ScanEntraAdminRolesJob implements ShouldQueue
{
public function __construct(
public int $tenantId,
public int $workspaceId,
public ?int $initiatorUserId = null,
) {}
// Creates/reuses OperationRun via OperationRunService
// Calls EntraAdminRolesReportService::generate()
// Calls EntraAdminRolesFindingGenerator::generate()
// Records outcome on OperationRun
}
New Config: config/entra_permissions.php
return [
'permissions' => [
[
'key' => 'RoleManagement.Read.Directory',
'type' => 'application',
'description' => 'Read directory role definitions and assignments for Entra admin roles evidence.',
'features' => ['entra-admin-roles'],
],
],
];
New Graph Contracts (in config/graph_contracts.php)
'entraRoleDefinitions' => [
'resource' => 'roleManagement/directory/roleDefinitions',
'allowed_select' => ['id', 'displayName', 'templateId', 'isBuiltIn'],
'allowed_expand' => [],
],
'entraRoleAssignments' => [
'resource' => 'roleManagement/directory/roleAssignments',
'allowed_select' => ['id', 'roleDefinitionId', 'principalId', 'directoryScopeId'],
'allowed_expand' => ['principal'],
],
Modified Badge: FindingTypeBadge
Add mapping for entra_admin_roles:
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec(
'Entra admin roles',
'danger',
'heroicon-m-identification',
),
Entity Relationship Summary
Workspace 1──N Tenant 1──N StoredReport (report_type=entra.admin_roles)
1──N Finding (finding_type=entra_admin_roles)
1──N OperationRun (type=entra.admin_roles.scan)
Workspace 1──N AlertRule (event_type=entra.admin_roles.high)
1──N AlertDelivery
ScanEntraAdminRolesJob
├── creates OperationRun
├── calls EntraAdminRolesReportService → StoredReport
└── calls EntraAdminRolesFindingGenerator → Finding[] + AlertEvent[]
└── EvaluateAlertsJob → AlertDelivery[]