# Implementation Plan: Entra Admin Roles Evidence + Findings **Branch**: `105-entra-admin-roles-evidence-findings` | **Date**: 2026-02-21 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `/specs/105-entra-admin-roles-evidence-findings/spec.md` ## Summary Implement an Entra Admin Roles evidence and findings pipeline that scans a tenant's directory role definitions and active role assignments via Microsoft Graph, persists results as fingerprinted stored reports, generates severity-classified findings for high-privilege assignments, integrates with the existing alerts pipeline, adds a tenant dashboard widget, and extends the permission posture with Entra-specific Graph permissions. The implementation reuses infrastructure from Spec 104 (StoredReports, fingerprint-based dedup, posture pattern) and the existing findings/alerts framework. ## Technical Context **Language/Version**: PHP 8.4 (Laravel 12) **Primary Dependencies**: Filament v5, Livewire v4, Pest v4 **Storage**: PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence **Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`) **Target Platform**: Linux server (Docker/Sail locally, Dokploy for staging/production) **Project Type**: Web application (Laravel monolith) **Performance Goals**: Single tenant scan completes within 30s for up to 200 role assignments; alert delivery queued within 2 mins of finding creation **Constraints**: No external API calls from finding generator (reads report payload); all Graph calls via contract path; all work is queued **Scale/Scope**: Up to ~50 tenants per workspace, ~200 role assignments per tenant, ~6 high-privilege role types in v1 catalog ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - [x] **Inventory-first**: Admin roles scan captures "last observed" active role assignments from Graph. StoredReports are immutable evidence snapshots, not configuration. Findings represent current posture analysis on observed data. - [x] **Read/write separation**: Scan is 100% read-only against Entra (no writes). StoredReports are immutable. Findings are system-generated — no manual create/edit. No preview/dry-run needed (non-destructive). - [x] **Graph contract path**: Two new Graph endpoints registered in `config/graph_contracts.php`: `entraRoleDefinitions` and `entraRoleAssignments`. All calls go through `GraphClientInterface`. No hardcoded endpoints. - [x] **Deterministic capabilities**: High-privilege classification is deterministic via `HighPrivilegeRoleCatalog` (static `template_id` → severity map). Severity derivation is testable via unit tests. New capabilities `ENTRA_ROLES_VIEW` and `ENTRA_ROLES_MANAGE` added to canonical `Capabilities` registry. - [x] **RBAC-UX**: Tenant-context routes remain tenant-scoped. Non-member → 404. Member without `ENTRA_ROLES_VIEW` → 403 for Admin Roles card/report. Member without `ENTRA_ROLES_MANAGE` → 403 for scan trigger. Findings list uses existing `FINDINGS_VIEW` (no dual-gate). Capabilities referenced via constants only (no raw strings). - [x] **Workspace isolation**: StoredReports include `workspace_id` (NOT NULL). Findings derive workspace via `DerivesWorkspaceIdFromTenant`. Widget resolves tenant via `Filament::getTenant()`. Non-member workspace access → 404. - [x] **RBAC-UX (destructive confirmation)**: No destructive actions. "Scan now" is a non-destructive read-only operation — no confirmation required. - [x] **RBAC-UX (global search)**: No new globally searchable resources. Existing search behavior unchanged. - [x] **Tenant isolation**: All findings, stored reports, and operation runs are scoped via `tenant_id` (NOT NULL). Cross-tenant access impossible at query level. Widget renders only current tenant data. - [x] **Run observability**: Each scan execution tracked as `OperationRun` with `type=entra.admin_roles.scan`. Start surfaces enqueue only (`ScanEntraAdminRolesJob` dispatched). OperationRun records status, timestamps, counts, and failures. Active-run uniqueness enforced per `(workspace_id, tenant_id, run_type)`. - [x] **Automation**: `ScanEntraAdminRolesJob` uses `OperationRunService::ensureRunWithIdentity()` for dedup. Fingerprint-based upsert handles concurrency for findings. Graph throttling handled by existing `GraphClientInterface` retry logic (429/503 backoff+jitter). - [x] **Data minimization**: Report payload contains only governance-relevant data (IDs, display names, principal types, scopes). No tokens, secrets, or excessive PII. Logs use stable error codes — no secrets/tokens in run failures. - [x] **Badge semantics (BADGE-001)**: `finding_type=entra_admin_roles` added to centralized `FindingTypeBadge` mapper with `BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification')`. Tests included. - [x] **Filament UI Action Surface Contract**: No new Resources/RelationManagers/Pages. Widget is a simple card with "Scan now" header action and "View latest report" link. Exemption: Widget is not a full Resource — Action Surface Contract does not apply to simple stat/card widgets. Documented in spec UI Action Matrix. - [x] **UX-001 (Layout & IA)**: No new Create/Edit/View pages. Widget follows existing card widget conventions. Report viewer reuses existing stored reports page. Exemption documented in spec. - [x] **SCOPE-001 ownership**: StoredReports are tenant-owned (`workspace_id` + `tenant_id` NOT NULL). Findings are tenant-owned (existing). AlertRule is workspace-owned (existing, no change). **Post-Phase-1 re-check**: All items pass. No violations found. ## Project Structure ### Documentation (this feature) ```text specs/105-entra-admin-roles-evidence-findings/ ├── plan.md # This file ├── spec.md # Feature specification ├── research.md # Phase 0: research decisions (R1-R10) ├── data-model.md # Phase 1: entity/table design ├── quickstart.md # Phase 1: implementation guide ├── contracts/ │ └── internal-services.md # Phase 1: service contracts ├── checklists/ │ └── requirements.md # Quality checklist (created by /speckit.tasks) └── tasks.md # Phase 2 output (created by /speckit.tasks) ``` ### Source Code (repository root) ```text app/ ├── Models/ │ ├── StoredReport.php # MODIFIED: +REPORT_TYPE_ENTRA_ADMIN_ROLES, +fingerprint fillables │ ├── Finding.php # MODIFIED: +FINDING_TYPE_ENTRA_ADMIN_ROLES constant │ └── AlertRule.php # MODIFIED: +EVENT_ENTRA_ADMIN_ROLES_HIGH constant ├── Services/ │ ├── EntraAdminRoles/ │ │ ├── HighPrivilegeRoleCatalog.php # NEW: role classification (template_id → severity) │ │ ├── EntraAdminRolesReportService.php # NEW: Graph fetch + report creation + dedup │ │ ├── EntraAdminRolesReportResult.php # NEW: value object │ │ ├── EntraAdminRolesFindingGenerator.php # NEW: findings lifecycle (create/resolve/reopen) │ │ └── EntraAdminRolesFindingResult.php # NEW: value object │ └── Intune/ │ └── TenantPermissionService.php # MODIFIED: merge entra_permissions into getRequiredPermissions() ├── Jobs/ │ ├── ScanEntraAdminRolesJob.php # NEW: queued orchestrator job │ └── Alerts/ │ └── EvaluateAlertsJob.php # MODIFIED: +entraAdminRolesHighEvents() method ├── Support/ │ ├── OperationRunType.php # MODIFIED: +EntraAdminRolesScan case │ ├── Auth/ │ │ ├── Capabilities.php # MODIFIED: +ENTRA_ROLES_VIEW, +ENTRA_ROLES_MANAGE │ │ └── RoleCapabilityMap.php # MODIFIED: map new capabilities to roles │ └── Badges/Domains/ │ └── FindingTypeBadge.php # MODIFIED: +entra_admin_roles badge mapping ├── Filament/ │ ├── Resources/ │ │ └── AlertRuleResource.php # MODIFIED: +EVENT_ENTRA_ADMIN_ROLES_HIGH in dropdown │ └── Widgets/Tenant/ │ └── AdminRolesSummaryWidget.php # NEW: tenant dashboard card widget └── Providers/ └── (no changes) config/ ├── entra_permissions.php # NEW: Entra permission registry └── graph_contracts.php # MODIFIED: +entraRoleDefinitions, +entraRoleAssignments database/ ├── migrations/ │ └── XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns └── factories/ └── FindingFactory.php # MODIFIED: +entraAdminRoles() state resources/views/filament/widgets/tenant/ └── admin-roles-summary.blade.php # NEW: card template routes/ └── console.php # MODIFIED: schedule daily admin roles scan tests/Feature/EntraAdminRoles/ ├── HighPrivilegeRoleCatalogTest.php # NEW ├── EntraAdminRolesReportServiceTest.php # NEW ├── EntraAdminRolesFindingGeneratorTest.php # NEW ├── ScanEntraAdminRolesJobTest.php # NEW ├── AdminRolesAlertIntegrationTest.php # NEW ├── AdminRolesSummaryWidgetTest.php # NEW ├── EntraPermissionsRegistryTest.php # NEW └── StoredReportFingerprintTest.php # NEW ``` **Structure Decision**: Standard Laravel monolith structure. New services go under `app/Services/EntraAdminRoles/`. Tests mirror the service structure under `tests/Feature/EntraAdminRoles/`. Widget follows existing tenant dashboard card pattern. ## Complexity Tracking > No constitution violations. No complexity justifications needed. ## Implementation Phases ### Phase A — Foundation (Migration + Constants + Config) **Goal**: Establish the data layer, config, and constants that all other phases depend on. **Deliverables**: 1. Migration: `add_fingerprint_to_stored_reports` — add `fingerprint` (string(64), nullable), `previous_fingerprint` (string(64), nullable), unique index on `[tenant_id, report_type, fingerprint]`, index on `[tenant_id, report_type, created_at DESC]` 2. Config: `config/entra_permissions.php` — registry with `RoleManagement.Read.Directory` (type: application, features: ['entra-admin-roles']) 3. Config: Add `entraRoleDefinitions` and `entraRoleAssignments` entries to `config/graph_contracts.php` 4. Model: `StoredReport` — add `REPORT_TYPE_ENTRA_ADMIN_ROLES` constant, add `fingerprint` + `previous_fingerprint` to fillable 5. Model: `Finding` — add `FINDING_TYPE_ENTRA_ADMIN_ROLES` constant 6. Model: `AlertRule` — add `EVENT_ENTRA_ADMIN_ROLES_HIGH` constant 7. Enum: `OperationRunType` — add `EntraAdminRolesScan` case 8. Constants: `Capabilities` — add `ENTRA_ROLES_VIEW`, `ENTRA_ROLES_MANAGE` 9. Constants: `RoleCapabilityMap` — map new capabilities (Readonly/Operator → VIEW; Manager/Owner → MANAGE) 10. Badge: Add `entra_admin_roles` mapping to `FindingTypeBadge` 11. Factory: Add `entraAdminRoles()` state to `FindingFactory` 12. Tests: StoredReport fingerprint migration (column exists), badge rendering, capabilities registry **Dependencies**: None (foundation layer). ### Phase B — High-Privilege Catalog + Report Service **Goal**: Implement role classification and the Graph-backed report generation service. **Deliverables**: 1. Service: `HighPrivilegeRoleCatalog` — static catalog with `classify()`, `isHighPrivilege()`, `isGlobalAdministrator()`, `allTemplateIds()` 2. Value object: `EntraAdminRolesReportResult` — `(created, storedReportId, fingerprint, payload)` 3. Service: `EntraAdminRolesReportService` — fetches Graph data, builds payload (FR-005), computes fingerprint, creates/deduplicates StoredReport, sets `previous_fingerprint` 4. Tests: Catalog classification (all 6 roles, display name fallback, unknown roles, null display name), report service (new report creation, dedup on identical fingerprint, previous_fingerprint chain, all-or-nothing on partial Graph failure, payload schema validation) **Dependencies**: Phase A (constants, config, migration). ### Phase C — Finding Generator **Goal**: Implement the finding lifecycle — create, upsert, auto-resolve, re-open, aggregate threshold. **Deliverables**: 1. Value object: `EntraAdminRolesFindingResult` — `(created, resolved, reopened, unchanged, alertEventsProduced)` 2. Service: `EntraAdminRolesFindingGenerator` — per (principal, role) findings with fingerprint-based idempotency, auto-resolve stale findings, re-open resolved findings, aggregate "Too many Global Admins" finding, alert event production 3. Tests: Finding creation (severity mapping), idempotent upsert (`times_seen` / `last_seen_at` update), auto-resolve on removed assignment, re-open on re-assigned role, aggregate finding (threshold exceeded/within), evidence schema, alert event production for new/re-opened findings, no events for unchanged/resolved **Dependencies**: Phase B (catalog, report service provides payload structure). ### Phase D — Scan Job + Scheduling + Permission Posture Integration **Goal**: Wire everything together as a queued job with scheduling and integrate Entra permissions into the posture pipeline. **Deliverables**: 1. Job: `ScanEntraAdminRolesJob` — creates OperationRun via `ensureRunWithIdentity()`, calls report service, calls finding generator, records outcome. Skips gracefully if no active provider connection. 2. Schedule: Register daily scan command in `routes/console.php` (iterate workspaces → tenants with active connections → dispatch per-tenant) 3. Modify: `TenantPermissionService::getRequiredPermissions()` — merge `config('entra_permissions.permissions', [])` alongside existing Intune permissions 4. Tests: Job dispatch + OperationRun lifecycle, skip-if-no-connection, error handling (Graph failure → run failure), permissions registry merge (Intune + Entra), posture score reflects Entra permission gaps **Dependencies**: Phase C (finding generator). ### Phase E — Alerts Integration **Goal**: Connect admin roles findings to the existing alert pipeline. **Deliverables**: 1. Modify: `EvaluateAlertsJob` — add `entraAdminRolesHighEvents()` method (query `finding_type=entra_admin_roles`, `status IN (new)`, `severity IN (high, critical)`, `updated_at > $windowStart`). Call in `handle()` alongside existing event methods. 2. UI: Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` to event type dropdown in `AlertRuleResource` 3. Tests: Alert event production, severity filtering, cooldown/dedupe, alert rule matching for new event type **Dependencies**: Phase D (job produces findings that generate alert events). ### Phase F — Tenant Dashboard Widget **Goal**: Provide a tenant dashboard card for admin roles posture at-a-glance. **Deliverables**: 1. Widget: `AdminRolesSummaryWidget` — extends `Widget`, resolves tenant via `Filament::getTenant()`, queries latest `stored_report` (type=entra.admin_roles), displays last scan timestamp + high-privilege count 2. View: `admin-roles-summary.blade.php` — card template with summary stats, empty state ("No scan performed"), "Scan now" CTA (gated by `ENTRA_ROLES_MANAGE`), "View latest report" link (gated by `ENTRA_ROLES_VIEW`) 3. RBAC: `canView()` gated by `ENTRA_ROLES_VIEW`. "Scan now" action checks `ENTRA_ROLES_MANAGE` server-side. 4. Tests: Widget renders with report data, empty state rendering, "Scan now" dispatches job (with RBAC), widget hidden without `ENTRA_ROLES_VIEW` **Dependencies**: Phases A-D complete (job, reports, constants). ## Filament v5 Agent Output Contract 1. **Livewire v4.0+ compliance**: Yes — `AdminRolesSummaryWidget` extends Filament's `Widget` (Livewire v4 component). No Livewire v3 references. 2. **Provider registration**: No new providers. Existing `AdminPanelProvider` in `bootstrap/providers.php` unchanged. 3. **Global search**: No new globally searchable resources. Existing Finding resource global search behavior unchanged. 4. **Destructive actions**: None introduced. "Scan now" is non-destructive (read-only Graph operation). No confirmation required. 5. **Asset strategy**: No new frontend assets. Badge mapping is PHP-only. Widget uses a simple Blade template. No `filament:assets` changes needed. 6. **Testing plan**: Pest feature tests for: HighPrivilegeRoleCatalog (classification), EntraAdminRolesReportService (Graph fetch + dedup), EntraAdminRolesFindingGenerator (create/resolve/reopen/aggregate/idempotency), ScanEntraAdminRolesJob (orchestration + OperationRun), AdminRolesAlertIntegration (event production + matching), AdminRolesSummaryWidget (Livewire component mount + rendering + RBAC), EntraPermissionsRegistry (merge correctness), StoredReportFingerprint (migration + dedup).