## 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
31 KiB
Tasks: Entra Admin Roles Evidence + Findings
Input: Design documents from /specs/105-entra-admin-roles-evidence-findings/
Prerequisites: plan.md (required), spec.md (required), research.md, data-model.md, contracts/internal-services.md, quickstart.md
Tests: For runtime behavior changes in this repo, tests are REQUIRED (Pest). All user stories include test tasks.
Operations: This feature introduces queued work (ScanEntraAdminRolesJob). Tasks include OperationRun creation via OperationRunService::ensureRunWithIdentity() with type entra.admin_roles.scan, and outcome tracking per constitution.
RBAC: New capabilities introduced: ENTRA_ROLES_VIEW and ENTRA_ROLES_MANAGE. Tasks include:
- Capability constants in
App\Support\Auth\Capabilities(no raw strings) RoleCapabilityMapmapping: Readonly/Operator → VIEW; Manager/Owner → MANAGE- Widget
canView()gated byENTRA_ROLES_VIEW; "Scan now" action gated byENTRA_ROLES_MANAGEserver-side - 404 vs 403 semantics: non-member/not entitled → 404; member missing capability → 403
- Authorization plane: tenant-context (
/admin/t/{tenant}/...) - No new globally searchable resources. Existing search behavior unchanged.
- No destructive actions — scan is read-only, no
->requiresConfirmation()needed - Positive + negative authorization tests in widget test (US5)
Filament UI Action Surfaces: Partial exemption — no new Resources/Pages/RelationManagers. Only changes: (1) new tenant card widget with "Scan now" header action, (2) new option added to
AlertRuleResourceevent type dropdown. Widget is not a full Resource — Action Surface Contract does not apply to simple stat/card widgets (documented in spec UI Action Matrix). Filament UI UX-001: Exemption — no new Create/Edit/View pages. Widget follows existing card conventions. Report viewer reuses existing stored reports viewer. Badges: Addsentra_admin_rolesfinding type toFindingTypeBadgeper BADGE-001 (BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification')). Tests included.
Organization: Tasks are grouped by user story to enable independent implementation and testing of each story.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies on incomplete tasks)
- [Story]: Which user story this task belongs to (US1, US2, US3, US4, US5, US6)
- Include exact file paths in descriptions
Phase 1: Setup
Purpose: No project setup needed — existing Laravel project with all framework dependencies.
Phase skipped.
Phase 2: Foundation (Blocking Prerequisites)
Purpose: Migration, config files, model constants, enum cases, capability registry, badge mappings, and factory states that ALL user stories depend on. Maps to plan Phase A.
⚠️ CRITICAL: No user story work can begin until this phase is complete.
Migration
- T001 Create migration for adding
fingerprint(string(64), nullable) andprevious_fingerprint(string(64), nullable) columns tostored_reportstable viavendor/bin/sail artisan make:migration add_fingerprint_to_stored_reports_table --no-interaction. Add unique index on[tenant_id, report_type, fingerprint]and index on[tenant_id, report_type, created_at DESC]. Columns are nullable because existingpermission_posturereports (Spec 104) don't use fingerprinting. Seespecs/105-entra-admin-roles-evidence-findings/data-model.mdmigration section.
Config Files
- T002 [P] Create
config/entra_permissions.phpwith permissions array containingRoleManagement.Read.Directory(type: application, features:['entra-admin-roles']). Follow exact schema of existingconfig/intune_permissions.php. Seespecs/105-entra-admin-roles-evidence-findings/data-model.mdconfig section. - T003 [P] Add
entraRoleDefinitionsandentraRoleAssignmentsentries toconfig/graph_contracts.php.entraRoleDefinitions: resourceroleManagement/directory/roleDefinitions, allowed_select[id, displayName, templateId, isBuiltIn].entraRoleAssignments: resourceroleManagement/directory/roleAssignments, allowed_select[id, roleDefinitionId, principalId, directoryScopeId], allowed_expand[principal]. Seespecs/105-entra-admin-roles-evidence-findings/data-model.mdgraph contracts section.
Model & Enum Constants
- T004 [P] Extend
StoredReportmodel inapp/Models/StoredReport.php: addREPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles'constant, addfingerprintandprevious_fingerprintto$fillablearray. Seespecs/105-entra-admin-roles-evidence-findings/data-model.mdStoredReport section. - T005 [P] Extend
Findingmodel inapp/Models/Finding.php: addFINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles'constant. No method changes — existingresolve(),reopen(), and fingerprint-based lookup are reused from Spec 104. - T006 [P] Extend
AlertRulemodel inapp/Models/AlertRule.php: addEVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high'constant. - T007 [P] Add
EntraAdminRolesScan = 'entra.admin_roles.scan'case toapp/Support/OperationRunType.phpenum.
Capability Registry
- T008 [P] Add
ENTRA_ROLES_VIEW = 'entra_roles.view'andENTRA_ROLES_MANAGE = 'entra_roles.manage'constants toapp/Support/Auth/Capabilities.php. - T009 [P] Update
app/Support/Auth/RoleCapabilityMap.phpto map new capabilities: Readonly/Operator →ENTRA_ROLES_VIEW; Manager/Owner →ENTRA_ROLES_VIEW+ENTRA_ROLES_MANAGE. Follow existing mapping patterns in the file.
Badge Mapping
- T010 [P] Add
entra_admin_rolesmapping toapp/Support/Badges/Domains/FindingTypeBadge.php:Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification'). Follow existing pattern frompermission_postureanddriftmappings.
Factory State
- T011 [P] Add
entraAdminRoles()factory state todatabase/factories/FindingFactory.php: setsfinding_type → Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,source → 'entra.admin_roles',severity → Finding::SEVERITY_CRITICAL,subject_type → 'role_assignment', sample evidence inevidence_jsonbwithrole_display_name,principal_display_name,principal_type,principal_id,role_definition_id,directory_scope_id,is_built_in,measured_at.
Verify & Test Foundation
- T012 Run migration via
vendor/bin/sail artisan migrateand verifystored_reportstable hasfingerprintandprevious_fingerprintcolumns, unique index on[tenant_id, report_type, fingerprint], and index on[tenant_id, report_type, created_at DESC]. - T013 [P] Write StoredReport fingerprint tests in
tests/Feature/EntraAdminRoles/StoredReportFingerprintTest.php: (1)fingerprintandprevious_fingerprintcolumns are fillable and persist correctly, (2) unique index prevents duplicate(tenant_id, report_type, fingerprint)combinations, (3) nullable columns allow null values for existing reports without fingerprints. - T014 [P] Write badge rendering test for
entra_admin_rolestype intests/Feature/EntraAdminRoles/FindingTypeBadgeTest.php(or extend existing badge test file):entra_admin_rolesrenders withdangercolor andheroicon-m-identificationicon. - T015 [P] Write capabilities registry test in
tests/Feature/EntraAdminRoles/EntraPermissionsRegistryTest.php: (1)Capabilities::ENTRA_ROLES_VIEWandCapabilities::ENTRA_ROLES_MANAGEconstants exist, (2)RoleCapabilityMapmaps VIEW to Readonly/Operator, MANAGE to Manager/Owner.
Checkpoint: Foundation ready — migration, config, constants, capabilities, badge, and factory in place. User story implementation can begin.
Phase 3: User Story 1 — Scan & Evidence Snapshot (Priority: P1) 🎯 MVP
Goal: Fetch Entra directory role data from Graph, classify high-privilege roles, and persist as a fingerprinted stored report with deduplication. Maps to plan Phase B.
Independent Test: Trigger report generation for a tenant; confirm a stored_report exists with report_type=entra.admin_roles, valid payload (role_definitions, role_assignments, totals, high_privilege), and content-based fingerprint.
Implementation for User Story 1
- T016 [P] [US1] Create
HighPrivilegeRoleCataloginapp/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.phpwith staticCATALOGmap (6 template_id → severity pairs) andDISPLAY_NAME_FALLBACKmap (case-insensitive). Methods:classify(string $templateIdOrId, ?string $displayName): ?string(returns severity or null),isHighPrivilege(...): bool,isGlobalAdministrator(...): bool,allTemplateIds(): array. Classification preferstemplate_id, falls back todisplay_name. Seespecs/105-entra-admin-roles-evidence-findings/data-model.mdHighPrivilegeRoleCatalog section and spec FR-006. - T017 [P] [US1] Create
EntraAdminRolesReportResultvalue object inapp/Services/EntraAdminRoles/EntraAdminRolesReportResult.phpwithreadonlyproperties:bool $created,?int $storedReportId,string $fingerprint,array $payload. Seespecs/105-entra-admin-roles-evidence-findings/data-model.md. - T018 [US1] Create
EntraAdminRolesReportServiceinapp/Services/EntraAdminRoles/EntraAdminRolesReportService.phpwith constructor injectingGraphClientInterfaceandHighPrivilegeRoleCatalog. Methodgenerate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResultmust: (1) fetchroleDefinitionsvia Graph contractentraRoleDefinitions, (2) fetchroleAssignmentswith$expand=principalvia contractentraRoleAssignments, (3) build payload per FR-005 schema (provider_key, domain, measured_at, role_definitions, role_assignments, totals, high_privilege), (4) compute fingerprint: SHA-256 of sorted"{role_template_or_id}:{principal_id}:{scope_id}"tuples joined by\n, (5) check if latest report for(tenant_id, report_type=entra.admin_roles)has same fingerprint → returncreated=falseif match, (6) createStoredReportwithprevious_fingerprintfrom latest existing report. All-or-nothing: if either Graph call fails, throw (no partial report). Seespecs/105-entra-admin-roles-evidence-findings/contracts/internal-services.mdService 1.
Tests for User Story 1
- T019 [P] [US1] Write HighPrivilegeRoleCatalog tests in
tests/Feature/EntraAdminRoles/HighPrivilegeRoleCatalogTest.php: (1) classify Global Administrator template_id →critical, (2) classify all 5 other high-privilege template_ids →high, (3) display_name fallback for role without template_id match, (4) unknown template_id + unknown display_name → null, (5) null display_name with unknown template_id → null, (6)isGlobalAdministrator()returns true only for GA template_id, (7)allTemplateIds()returns all 6 entries, (8) display_name matching is case-insensitive. - T020 [US1] Write EntraAdminRolesReportService tests in
tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php: (1) new report created with correctreport_type=entra.admin_roles, payload schema per FR-005, and fingerprint, (2) dedup on identical fingerprint — second call returnscreated=falsewith existing report ID, (3) changed data → new report with different fingerprint andprevious_fingerprintchain, (4) Graph roleDefinitions failure → exception thrown, no partial report (all-or-nothing), (5) Graph roleAssignments failure → exception thrown, no partial report, (6) payload contains role_definitions, role_assignments, totals (roles_total, assignments_total, high_privilege_assignments), and high_privilege section, (7) fingerprint is deterministic regardless of Graph response ordering, (8) tenant with zero role assignments → valid report with empty assignments and totals.
Checkpoint: US1 complete — Entra admin role data is fetched from Graph, classified, fingerprinted, and persisted as stored reports with deduplication. This is the evidence pipeline.
Phase 4: User Story 2 — Generate Findings for High-Privilege Assignments (Priority: P1)
Goal: Generate, upsert, auto-resolve, and re-open findings based on report data. Create aggregate "Too many Global Admins" finding when threshold exceeded. Wire everything via the scan job with OperationRun tracking and scheduling. Maps to plan Phases C + D.
Independent Test: Run the finding generator for a tenant with a Global Administrator assignment; confirm a finding with finding_type=entra_admin_roles, severity=critical, correct fingerprint and evidence exists.
Implementation for User Story 2
- T021 [P] [US2] Create
EntraAdminRolesFindingResultvalue object inapp/Services/EntraAdminRoles/EntraAdminRolesFindingResult.phpwithreadonlyproperties:int $created,int $resolved,int $reopened,int $unchanged,int $alertEventsProduced. Seespecs/105-entra-admin-roles-evidence-findings/data-model.md. - T022 [US2] Create
EntraAdminRolesFindingGeneratorinapp/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.phpwith constructor injectingHighPrivilegeRoleCatalog. Methodgenerate(Tenant $tenant, array $reportPayload, ?OperationRun $operationRun = null): EntraAdminRolesFindingResultmust: (1) iteraterole_assignmentsfrom payload, classify each via catalog, (2) for each high-privilege assignment: create finding via fingerprint upsert (firstOrNewon[tenant_id, fingerprint]), fingerprint =substr(hash('sha256', "entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}"), 0, 64), (3) set severity:criticalfor Global Admin,highfor others, (4) set evidence per FR-009 schema,subject_type='role_assignment',subject_external_id="{principal_id}:{role_definition_id}", (5) auto-resolve stale findings: query openentra_admin_rolesfindings whose fingerprint NOT in current scan →resolve('role_assignment_removed'), (6) re-open resolved findings when fingerprint matches current scan, (7) aggregate finding: when GA count > 5 → create "Too many Global Admins" finding with fingerprintsubstr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64), severity=high, auto-resolve when ≤ 5, (8) produce alert events for new/re-opened findings with severity ≥ high. MethodgetAlertEvents(): arrayreturns collected events. Seespecs/105-entra-admin-roles-evidence-findings/contracts/internal-services.mdService 2. - T023 [US2] Create
ScanEntraAdminRolesJobinapp/Jobs/ScanEntraAdminRolesJob.phpimplementingShouldQueue. Constructor:int $tenantId,int $workspaceId,?int $initiatorUserId = null.handle()must: (1) resolve Tenant, check for active provider connection → return early if none (no OperationRun, no error per FR-018), (2) create OperationRun viaOperationRunService::ensureRunWithIdentity()with typeentra.admin_roles.scan, (3) callEntraAdminRolesReportService::generate(), (4) callEntraAdminRolesFindingGenerator::generate()with report payload (regardless of whether report was new or deduped — finding generator handles auto-resolve for stale findings from removed assignments), (5) record success on OperationRun with counts from both results, (6) on Graph error → record failure with sanitized error message, re-throw for retry. Seespecs/105-entra-admin-roles-evidence-findings/contracts/internal-services.mdJob section. - T024 [US2] Register daily scan schedule in
routes/console.php: iterate workspaces → tenants with active provider connections → dispatchScanEntraAdminRolesJobper tenant. Follow existing scheduled scan patterns in the file. See plan Phase D (scheduling) and FR-016.
Tests for User Story 2
- T025 [US2] Write EntraAdminRolesFindingGenerator tests in
tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php: (1) creates individual findings for high-privilege assignments with correct type/severity/fingerprint/evidence/source, (2) severity mapping: Global Admin → critical, others → high, (3) idempotent upsert — same data across scans → no duplicates,times_seenandlast_seen_atupdated, (4) auto-resolve on removed assignment (finding status → resolved, resolved_reason =role_assignment_removed), (5) re-open resolved finding when role re-assigned (status → new, resolved_at/resolved_reason cleared, evidence updated), (6) aggregate "Too many Global Admins" finding created when GA count > 5 with correct fingerprint/severity/evidence, (7) aggregate finding auto-resolved when GA count ≤ 5, (8) alert events produced for new/re-opened findings with severity ≥ high, (9) no alert events for unchanged or resolved findings, (10) evidence schema includes role_display_name, principal_display_name, principal_type, principal_id, role_definition_id, directory_scope_id, is_built_in, measured_at, (11) handles all principal types (user, group, servicePrincipal), (12) subject_type='role_assignment' and subject_external_id='{principal_id}:{role_definition_id}' set on every finding, (13) stale findings for assignments no longer in scan are auto-resolved (catches removed assignments), (14) auto-resolve applies to bothnewandacknowledgedfindings (acknowledged metadata preserved), (15) scoped assignments (directory_scope_id != '/') do not downgrade severity and the scope is captured in evidence. - T026 [US2] Write ScanEntraAdminRolesJob tests in
tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php: (1) successful run creates OperationRun with type=entra.admin_roles.scan and records success with counts, (2) skips tenant without active provider connection — no OperationRun, no findings, no report, (3) Graph failure → OperationRun marked failed with sanitized error, job re-thrown for retry, (4) finding generator called even when report was deduped (handles auto-resolve), (5) OperationRun uniqueness enforced per (workspace_id, tenant_id, run_type).
Checkpoint: US1 + US2 complete — full scan pipeline operational: Graph fetch → stored report → findings → auto-resolve → re-open → aggregate. This is the core MVP.
Phase 5: User Story 4 — Entra Permissions in Permission Posture (Priority: P2)
Goal: Integrate Entra-specific Graph permissions into the existing permission posture pipeline so posture scores reflect whether tenants can run admin role scans. Maps to plan Phase D (TenantPermissionService merge).
Independent Test: Verify that after loading the merged registry (Intune + Entra), RoleManagement.Read.Directory appears in the required permissions list. Verify posture score computation includes it.
Implementation for User Story 4
- T027 [US4] Modify
app/Services/Intune/TenantPermissionService.phpmethodgetRequiredPermissions()to mergeconfig('entra_permissions.permissions', [])alongside existingconfig('intune_permissions.permissions', []). The merge must be non-breaking (existing Intune posture flows unchanged). Seespecs/105-entra-admin-roles-evidence-findings/contracts/internal-services.mdModified: TenantPermissionService section.
Tests for User Story 4
- T028 [US4] Extend or create tests in
tests/Feature/EntraAdminRoles/EntraPermissionsRegistryTest.php: (1) merged required permissions list includes both Intune and Entra entries, (2)RoleManagement.Read.Directoryappears in merged list with correct type and features, (3) existing Intune permissions unchanged after merge, (4) emptyentra_permissions.permissionsconfig → merge returns only Intune entries (non-breaking fallback), (5) posture score reflects the Entra permission gap (missingRoleManagement.Read.Directoryresults in score < 100).
Checkpoint: US4 complete — posture scores accurately reflect Entra permission gaps.
Phase 6: User Story 3 — Alert on High-Privilege Admin Role Events (Priority: P2)
Goal: Connect admin roles findings to the existing alert pipeline so operators receive notifications for new high-privilege assignments. Maps to plan Phase E.
Independent Test: Create an alert rule for entra.admin_roles.high with min severity = high, run the finding generator with a new Global Admin assignment, and confirm a delivery is queued.
Implementation for User Story 3
- T029 [US3] Add
entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): arraymethod toapp/Jobs/Alerts/EvaluateAlertsJob.php— queriesFindingwherefinding_type='entra_admin_roles',status IN ('new'),severity IN ('high', 'critical'),updated_at > $windowStart. Returns event arrays matching existing event schema (event_type=entra.admin_roles.high,fingerprint_key=finding:{id}). Wire the method intohandle()event collection alongside existinghighDriftEvents(),compareFailedEvents(), andpermissionMissingEvents(). Follow same pattern as existing event methods. - T030 [P] [US3] Add
EVENT_ENTRA_ADMIN_ROLES_HIGHoption to event type dropdown inapp/Filament/Resources/AlertRuleResource.php:AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)'.
Tests for User Story 3
- T031 [US3] Write alert integration tests in
tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php: (1) alert rule for EVENT_ENTRA_ADMIN_ROLES_HIGH with min severity = high + new finding of severity = critical → delivery queued, (2) alert rule with min severity = critical + finding of severity = high → no delivery queued, (3) cooldown/dedupe prevents duplicate notifications for same finding across scans (fingerprint_key-based suppression), (4) resolved findings do not produce alert events, (5) new event type appears in AlertRuleResource event type options.
Checkpoint: US3 complete — operators receive alerts for high-privilege admin role events via existing alert channels.
Phase 7: User Story 5 — Dashboard Widget (Priority: P3) + User Story 6 — Report Viewer (Priority: P3)
Goal (US5): Provide a tenant dashboard card showing admin roles posture at-a-glance with "Scan now" CTA and "View latest report" link.
Goal (US6): Ensure existing stored reports viewer supports filtering by entra.admin_roles report type.
Maps to plan Phase F.
Independent Test (US5): Navigate to a tenant dashboard after a scan; confirm the card shows correct timestamp and high-privilege count.
Independent Test (US6): Navigate to stored reports viewer filtered by entra.admin_roles; confirm report displays summary and high-privilege assignments table.
Implementation for User Story 5
- T032 [P] [US5] Create
admin-roles-summary.blade.phpinresources/views/filament/widgets/tenant/admin-roles-summary.blade.php— card template with: summary stats (last scan timestamp, high-privilege count), empty state ("No scan performed"), "Scan now" CTA (gated byENTRA_ROLES_MANAGE), "View latest report" link (gated byENTRA_ROLES_VIEW). Follow existing tenant card widget templates. - T033 [US5] Create
AdminRolesSummaryWidgetinapp/Filament/Widgets/Tenant/AdminRolesSummaryWidget.phpextendingWidget. Must: (1) resolve tenant viaFilament::getTenant(), (2) query latestStoredReportwherereport_type=entra.admin_rolesandtenant_idmatches, (3) extract last scan timestamp + high-privilege count from payload, (4) implementcanView()gated byENTRA_ROLES_VIEWcapability viaGate::check()(server-side enforcement, not just UI hiding), (5) "Scan now" action dispatchesScanEntraAdminRolesJobafter checkingENTRA_ROLES_MANAGEserver-side, (6) render empty state when no report exists. Follow existing tenant dashboard widget patterns.
Tests for User Story 5
- T034 [US5] Write widget tests in
tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php(Livewire component test): (1) widget renders with report data — shows timestamp and high-privilege count, (2) empty state renders "No scan performed" when no report exists, (3) "Scan now" dispatchesScanEntraAdminRolesJobfor user withENTRA_ROLES_MANAGE, (4) "Scan now" not visible / returns 403 for user withENTRA_ROLES_VIEWbut withoutENTRA_ROLES_MANAGE, (5) widget hidden (canView returns false) for user withoutENTRA_ROLES_VIEW, (6) non-member → 404.
Tests for User Story 6
- T035 [US6] Write report viewer integration test in
tests/Feature/EntraAdminRoles/AdminRolesReportViewerTest.php: first confirm the existing stored reports viewer can render theentra.admin_rolespayload as a summary + high-privilege assignments table; then assert: (1) stored reports viewer shows reports withreport_type=entra.admin_roles, (2) report displays summary totals and high-privilege assignments table from payload, (3) multiple reports ordered by creation date descending.
Checkpoint: US5 + US6 complete — tenant dashboard shows admin roles posture card, and stored reports viewer supports the new report type.
Phase 8: Polish & Cross-Cutting Concerns
Purpose: Code quality, full test pass, and end-to-end validation.
- T036 Run
vendor/bin/sail bin pint --dirtyto fix formatting across all modified/created files. - T037 Run full Spec 105 test suite:
vendor/bin/sail artisan test --compact --filter=EntraAdminRolesand verify all tests pass. - T038 Run quickstart.md validation: verify all files listed in quickstart.md "New Files Created" and "Modified Files Summary" tables exist and match the implementation.
Dependencies & Execution Order
Phase Dependencies
Phase 2 (Foundation) ─────┬──> Phase 3 (US1 - P1) ──> Phase 4 (US2 - P1) 🎯 MVP
│ │
│ ├──> Phase 5 (US4 - P2)
│ ├──> Phase 6 (US3 - P2)
│ └──> Phase 7 (US5+US6 - P3)
│
└── BLOCKS all user story work
Phase 5, Phase 6, Phase 7 can proceed in parallel after Phase 4
Phase 8 (Polish) depends on all phases complete
- Foundation (Phase 2): No dependencies — start immediately. BLOCKS all user stories.
- US1 (Phase 3): Depends on Phase 2 completion. Report service is prerequisite for finding generator.
- US2 (Phase 4): Depends on Phase 3 (finding generator uses report payload structure; scan job calls report service). Completing Phase 4 delivers the core MVP.
- US4 (Phase 5): Depends on Phase 4 (posture score context requires scan to be operational). Can run in parallel with Phase 6 and Phase 7.
- US3 (Phase 6): Depends on Phase 4 (finding generator produces alert events). Can run in parallel with Phase 5 and Phase 7.
- US5+US6 (Phase 7): Depends on Phase 4 (widget displays report data from completed scans). Can run in parallel with Phase 5 and Phase 6.
- Polish (Phase 8): Depends on all user stories being complete.
User Story Dependencies
- User Story 1 (P1): Can start after Foundation — no dependencies on other stories
- User Story 2 (P1): Depends on US1 (finding generator uses catalog + report payload structure)
- User Story 4 (P2): Independent of other stories after Phase 4 — only requires Foundation config
- User Story 3 (P2): Independent after Phase 4 — only requires finding generator to produce events
- User Story 5 (P3): Independent after Phase 4 — widget queries stored reports
- User Story 6 (P3): Independent after Phase 4 — tests existing viewer with new report type
Within Each User Story
- Value objects before services
- Services before jobs
- Jobs before schedule registration
- Implementation before tests
- Core logic before integration points
Parallel Opportunities
Phase 2 (Foundation):
- T002 + T003 (config files, independent)
- T004 + T005 + T006 + T007 (model/enum constants, independent files)
- T008 + T009 (capabilities, can be done together logically but different files)
- T010 + T011 (badge + factory, independent files)
- T013 + T014 + T015 (tests, independent test files)
Phase 3 (US1):
- T016 + T017 (HighPrivilegeRoleCatalog + ReportResult VO, independent files)
- T019 can start after T016 (tests catalog)
Phase 4 (US2):
- T021 can run in parallel with T016/T017 if started early (VO, no dependencies)
Phases 5 + 6 + 7 (after Phase 4 complete):
- T027 (US4) + T029 (US3) + T032+T033 (US5) — all independent of each other
- T030 (US3 AlertRuleResource change) can run in parallel with T029
Parallel Example: Foundation Phase
# Batch 1: Migration + Config (parallel)
T001: Create fingerprint migration
T002: Create config/entra_permissions.php
T003: Add Graph contract entries
# Batch 2: Constants + Capabilities + Badge + Factory (parallel, after migration written)
T004: StoredReport constant + fillables
T005: Finding constant
T006: AlertRule constant
T007: OperationRunType enum case
T008: Capabilities constants
T009: RoleCapabilityMap update
T010: FindingTypeBadge mapping
T011: FindingFactory state
# Batch 3: Run migration
T012: vendor/bin/sail artisan migrate
# Batch 4: Foundation tests (parallel)
T013: StoredReport fingerprint test
T014: Badge rendering test
T015: Capabilities registry test
Implementation Strategy
MVP First (User Story 1 + 2)
- Complete Phase 2: Foundation
- Complete Phase 3: User Story 1 (catalog + report service)
- Complete Phase 4: User Story 2 (finding generator + scan job + scheduling)
- STOP and VALIDATE: Run
vendor/bin/sail artisan test --compact --filter=EntraAdminRoles - Full scan pipeline operational: Graph → report → findings → auto-resolve → aggregate
Incremental Delivery
- Foundation → ready
- US1 (P1) → evidence pipeline: reports created from Graph data ✅
- US2 (P1) → findings + job: full scan pipeline with findings lifecycle ✅ MVP!
- US4 (P2) → posture: Entra permissions in posture scores ✅
- US3 (P2) → alerts: push notifications for high-privilege events ✅
- US5+US6 (P3) → UI: dashboard card + report viewer ✅
- Polish → formatting, full test pass, quickstart validation ✅
Each phase adds value without breaking previous phases.
Notes
- [P] tasks = different files, no dependencies on incomplete tasks
- [Story] label maps task to specific user story for traceability
- Fingerprint for individual findings:
sha256("entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}") - Fingerprint for aggregate finding:
sha256("entra_admin_role_ga_count:{tenant_id}") - Report fingerprint: SHA-256 of sorted
"{role_template_or_id}:{principal_id}:{scope_id}"tuples - Severity:
criticalfor Global Administrator,highfor all other high-privilege roles - Threshold for "Too many Global Admins": 5 (hardcoded v1, TODO for future settings)
- RBAC boundary:
ENTRA_ROLES_VIEWgates card + report only; findings use existingFINDINGS_VIEW - All Graph calls go through
GraphClientInterfacevia registered contracts - Scan is non-destructive (read-only) — no
->requiresConfirmation()needed - Commit after each task or logical group
- Stop at any checkpoint to validate story independently