## 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
17 KiB
Implementation Plan: Entra Admin Roles Evidence + Findings
Branch: 105-entra-admin-roles-evidence-findings | Date: 2026-02-21 | Spec: 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.
- 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.
- 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).
- Graph contract path: Two new Graph endpoints registered in
config/graph_contracts.php:entraRoleDefinitionsandentraRoleAssignments. All calls go throughGraphClientInterface. No hardcoded endpoints. - Deterministic capabilities: High-privilege classification is deterministic via
HighPrivilegeRoleCatalog(statictemplate_id→ severity map). Severity derivation is testable via unit tests. New capabilitiesENTRA_ROLES_VIEWandENTRA_ROLES_MANAGEadded to canonicalCapabilitiesregistry. - RBAC-UX: Tenant-context routes remain tenant-scoped. Non-member → 404. Member without
ENTRA_ROLES_VIEW→ 403 for Admin Roles card/report. Member withoutENTRA_ROLES_MANAGE→ 403 for scan trigger. Findings list uses existingFINDINGS_VIEW(no dual-gate). Capabilities referenced via constants only (no raw strings). - Workspace isolation: StoredReports include
workspace_id(NOT NULL). Findings derive workspace viaDerivesWorkspaceIdFromTenant. Widget resolves tenant viaFilament::getTenant(). Non-member workspace access → 404. - RBAC-UX (destructive confirmation): No destructive actions. "Scan now" is a non-destructive read-only operation — no confirmation required.
- RBAC-UX (global search): No new globally searchable resources. Existing search behavior unchanged.
- 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. - Run observability: Each scan execution tracked as
OperationRunwithtype=entra.admin_roles.scan. Start surfaces enqueue only (ScanEntraAdminRolesJobdispatched). OperationRun records status, timestamps, counts, and failures. Active-run uniqueness enforced per(workspace_id, tenant_id, run_type). - Automation:
ScanEntraAdminRolesJobusesOperationRunService::ensureRunWithIdentity()for dedup. Fingerprint-based upsert handles concurrency for findings. Graph throttling handled by existingGraphClientInterfaceretry logic (429/503 backoff+jitter). - 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.
- Badge semantics (BADGE-001):
finding_type=entra_admin_rolesadded to centralizedFindingTypeBadgemapper withBadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification'). Tests included. - 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.
- 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.
- SCOPE-001 ownership: StoredReports are tenant-owned (
workspace_id+tenant_idNOT 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)
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)
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:
- Migration:
add_fingerprint_to_stored_reports— addfingerprint(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] - Config:
config/entra_permissions.php— registry withRoleManagement.Read.Directory(type: application, features: ['entra-admin-roles']) - Config: Add
entraRoleDefinitionsandentraRoleAssignmentsentries toconfig/graph_contracts.php - Model:
StoredReport— addREPORT_TYPE_ENTRA_ADMIN_ROLESconstant, addfingerprint+previous_fingerprintto fillable - Model:
Finding— addFINDING_TYPE_ENTRA_ADMIN_ROLESconstant - Model:
AlertRule— addEVENT_ENTRA_ADMIN_ROLES_HIGHconstant - Enum:
OperationRunType— addEntraAdminRolesScancase - Constants:
Capabilities— addENTRA_ROLES_VIEW,ENTRA_ROLES_MANAGE - Constants:
RoleCapabilityMap— map new capabilities (Readonly/Operator → VIEW; Manager/Owner → MANAGE) - Badge: Add
entra_admin_rolesmapping toFindingTypeBadge - Factory: Add
entraAdminRoles()state toFindingFactory - 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:
- Service:
HighPrivilegeRoleCatalog— static catalog withclassify(),isHighPrivilege(),isGlobalAdministrator(),allTemplateIds() - Value object:
EntraAdminRolesReportResult—(created, storedReportId, fingerprint, payload) - Service:
EntraAdminRolesReportService— fetches Graph data, builds payload (FR-005), computes fingerprint, creates/deduplicates StoredReport, setsprevious_fingerprint - 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:
- Value object:
EntraAdminRolesFindingResult—(created, resolved, reopened, unchanged, alertEventsProduced) - 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 - Tests: Finding creation (severity mapping), idempotent upsert (
times_seen/last_seen_atupdate), 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:
- Job:
ScanEntraAdminRolesJob— creates OperationRun viaensureRunWithIdentity(), calls report service, calls finding generator, records outcome. Skips gracefully if no active provider connection. - Schedule: Register daily scan command in
routes/console.php(iterate workspaces → tenants with active connections → dispatch per-tenant) - Modify:
TenantPermissionService::getRequiredPermissions()— mergeconfig('entra_permissions.permissions', [])alongside existing Intune permissions - 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:
- Modify:
EvaluateAlertsJob— addentraAdminRolesHighEvents()method (queryfinding_type=entra_admin_roles,status IN (new),severity IN (high, critical),updated_at > $windowStart). Call inhandle()alongside existing event methods. - UI: Add
EVENT_ENTRA_ADMIN_ROLES_HIGHto event type dropdown inAlertRuleResource - 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:
- Widget:
AdminRolesSummaryWidget— extendsWidget, resolves tenant viaFilament::getTenant(), queries lateststored_report(type=entra.admin_roles), displays last scan timestamp + high-privilege count - View:
admin-roles-summary.blade.php— card template with summary stats, empty state ("No scan performed"), "Scan now" CTA (gated byENTRA_ROLES_MANAGE), "View latest report" link (gated byENTRA_ROLES_VIEW) - RBAC:
canView()gated byENTRA_ROLES_VIEW. "Scan now" action checksENTRA_ROLES_MANAGEserver-side. - 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
- Livewire v4.0+ compliance: Yes —
AdminRolesSummaryWidgetextends Filament'sWidget(Livewire v4 component). No Livewire v3 references. - Provider registration: No new providers. Existing
AdminPanelProviderinbootstrap/providers.phpunchanged. - Global search: No new globally searchable resources. Existing Finding resource global search behavior unchanged.
- Destructive actions: None introduced. "Scan now" is non-destructive (read-only Graph operation). No confirmation required.
- Asset strategy: No new frontend assets. Badge mapping is PHP-only. Widget uses a simple Blade template. No
filament:assetschanges needed. - 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).