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