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

117 lines
8.3 KiB
Markdown

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