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