## 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.3 KiB
Research: Entra Admin Roles Evidence + Findings (Spec 105)
Date: 2026-02-21 | Branch: 105-entra-admin-roles-evidence-findings
R1 — StoredReport Fingerprint + Deduplication
Decision: Add fingerprint and previous_fingerprint columns to stored_reports via a new migration.
Rationale: The spec requires content-based deduplication — if the role assignment data is identical between scans, no new report should be created. The existing stored_reports table (Spec 104) has no fingerprint columns. Spec 104's permission posture reports don't use fingerprinting (they always create a new report per compare run). Spec 105 introduces a different pattern: only store a new report when data has changed.
Migration:
- Add
fingerprint(string(64), nullable) — SHA-256 of sorted role assignment tuples - Add
previous_fingerprint(string(64), nullable) — references the prior report's fingerprint for drift chain - Add unique index on
[tenant_id, report_type, fingerprint]to enforce deduplication at DB level - Nullable because existing permission_posture reports have no fingerprint
Alternatives considered:
- Fingerprint as payload field (not a column): Rejected because DB-level uniqueness constraint prevents race conditions
- Separate table for Entra reports: Rejected because it fragments the report model unnecessarily
R2 — Graph API Pattern: roleManagement/directory
Decision: Register two new endpoints in config/graph_contracts.php under a new entraRoleDefinitions and entraRoleAssignments type section. Graph calls go through GraphClientInterface.
Rationale: The constitution requires all Graph calls to go through the contract registry. The unified RBAC API uses GET /roleManagement/directory/roleDefinitions and GET /roleManagement/directory/roleAssignments?$expand=principal. These are read-only, paginated endpoints returning standard Graph collections.
Implementation:
- New graph_contracts entry:
entraRoleDefinitions→roleManagement/directory/roleDefinitions - New graph_contracts entry:
entraRoleAssignments→roleManagement/directory/roleAssignments, withallowed_expand: ['principal'] - The service uses
GraphClientInterface::getCollection()(or equivalent paginated reader) to fetch all results
Pagination: Both endpoints return paginated results (standard @odata.nextLink). The existing paginated Graph reader pattern handles this.
R3 — HighPrivilegeRoleCatalog Classification Strategy
Decision: Pure PHP class with a static catalog mapping template_id → severity. Fallback to display_name matching when template_id is null (custom roles).
Rationale: Microsoft documents stable template_id values for built-in roles. These are GUIDs that don't change across tenants. Using template_id as primary key ensures cross-tenant consistency. Display name fallback handles edge cases where template_id is null.
v1 Catalog (from spec):
| Role | Template ID | Severity |
|---|---|---|
| Global Administrator | 62e90394-69f5-4237-9190-012177145e10 |
critical |
| Privileged Role Administrator | e8611ab8-c189-46e8-94e1-60213ab1f814 |
high |
| Security Administrator | 194ae4cb-b126-40b2-bd5b-6091b380977d |
high |
| Conditional Access Administrator | b1be1c3e-b65d-4f19-8427-f6fa0d97feb9 |
high |
| Exchange Administrator | 29232cdf-9323-42fd-ade2-1d097af3e4de |
high |
| Authentication Administrator | c4e39bd9-1100-46d3-8c65-fb160da0071f |
high |
Extensibility: The catalog class is a standalone value object — future specs can make this configurable per workspace via settings. v1 is hardcoded.
R4 — Finding Fingerprint Formula
Decision: entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id} hashed to SHA-256 (64 chars).
Rationale: This matches the existing pattern from PermissionPostureFindingGenerator which uses permission_posture:{tenant_id}:{permission_key}. Including all four dimensions (tenant, role, principal, scope) ensures uniqueness even if the same principal holds the same role at different scopes.
Aggregate finding (Too many GAs): entra_admin_role_ga_count:{tenant_id} — single fingerprint per tenant for the threshold finding.
Alternatives considered:
- Including
assignment_idfrom Graph: Rejected because assignment IDs can change when role assignments are recreated, causing false re-opens
R5 — Entra Permissions Registry Merge Strategy
Decision: Modify TenantPermissionService::getRequiredPermissions() to merge config/intune_permissions.php and config/entra_permissions.php.
Rationale: The current implementation at L23 hardcodes config('intune_permissions.permissions', []). The merge must:
- Load both registries
- Concatenate permission arrays
- Return the merged set
This is a minimal change (2-3 lines). The posture generator and compare logic are generic — they iterate the returned permissions array regardless of source.
Non-breaking guarantee: Existing Intune permissions appear unchanged in the merged array. Posture scores recompute to include Entra permissions proportionally.
R6 — Alert Event Integration
Decision: Add entraAdminRolesHighEvents() method to EvaluateAlertsJob, following the exact same pattern as highDriftEvents() and permissionMissingEvents().
Rationale: The existing alert evaluation job collects events from dedicated methods and dispatches them. Adding one more method that queries finding_type=entra_admin_roles with status=new and severity >= high within the time window follows the established pattern exactly.
Event structure: Same shape as existing events (event_type, tenant_id, severity, fingerprint_key, title, body, metadata). The fingerprint_key uses finding:{finding_id} for cooldown dedup.
R7 — OperationRunType for Scan
Decision: Add EntraAdminRolesScan = 'entra.admin_roles.scan' to the OperationRunType enum.
Rationale: The existing enum has cases like InventorySync, PolicySync, etc. The dot-notation value (entra.admin_roles.scan) follows the pattern of policy.sync, policy.sync_one, etc. Active-run uniqueness is enforced per (workspace_id, tenant_id, run_type) via OperationRunService::ensureRunWithIdentity().
R8 — Tenant Dashboard Widget Pattern
Decision: New AdminRolesSummaryWidget extending Filament\Widgets\Widget with a custom Blade view, matching the existing RecentOperationsSummary widget pattern.
Rationale: Existing tenant dashboard widgets (RecentOperationsSummary, TenantVerificationReport) use the same pattern: extend Widget, resolve the tenant from Filament context, query data, return view data. The Admin Roles card follows this exact pattern:
- Resolve tenant via
Filament::getTenant() - Query latest
stored_reportwherereport_type=entra.admin_roles - Extract summary stats (timestamp, high-privilege count)
- Return view data to Blade template
- "Scan now" action dispatches job (gated by
ENTRA_ROLES_MANAGE)
RBAC: The widget is gated by ENTRA_ROLES_VIEW via canView() method or static::canView(). The "Scan now" button checks ENTRA_ROLES_MANAGE separately.
R9 — No New Migration for Findings Table
Decision: No migration needed for the findings table.
Rationale: The existing findings table already has all required columns: finding_type, source, scope_key, fingerprint (unique per tenant), subject_type, subject_external_id, severity, status, evidence_jsonb, resolved_at, resolved_reason. Adding finding_type=entra_admin_roles is a new value in an existing string column — no schema change.
R10 — Scan Scheduling Pattern
Decision: Daily scheduled dispatch following existing workspace dispatcher pattern.
Rationale: The spec mentions "daily via workspace dispatcher alongside existing scheduled scans." The existing pattern in routes/console.php schedules a command that iterates workspaces and dispatches per-tenant jobs. The admin roles scan follows this same dispatching loop.
Implementation: Either extend the existing dispatcher or add a ScheduleEntraAdminRolesScanCommand that iterates workspaces → tenants with active connections → dispatches ScanEntraAdminRolesJob per tenant. On-demand scan uses the same job, dispatched from the widget action.