TenantAtlas/specs/105-entra-admin-roles-evidence-findings/research.md
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

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: entraRoleDefinitionsroleManagement/directory/roleDefinitions
  • New graph_contracts entry: entraRoleAssignmentsroleManagement/directory/roleAssignments, with allowed_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_id from 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:

  1. Load both registries
  2. Concatenate permission arrays
  3. 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:

  1. Resolve tenant via Filament::getTenant()
  2. Query latest stored_report where report_type=entra.admin_roles
  3. Extract summary stats (timestamp, high-privilege count)
  4. Return view data to Blade template
  5. "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.