TenantAtlas/specs/105-entra-admin-roles-evidence-findings/plan.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

220 lines
17 KiB
Markdown

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