# 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`, 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.