TenantAtlas/specs/105-entra-admin-roles-evidence-findings/plan.md
Ahmed Darrazi d25290d95e plan: spec 105 — Entra Admin Roles Evidence + Findings
Phase 0 research (R1-R10) + Phase 1 design artifacts:
- research.md: 10 decisions (fingerprint migration, Graph API, catalog, alerts)
- data-model.md: stored_reports migration, model/enum changes, new classes
- contracts/internal-services.md: 3 service + job contracts
- quickstart.md: implementation guide with file list + test commands
- plan.md: 6-phase implementation plan (A-F) with constitution check

Agent context: copilot-instructions.md updated
2026-02-22 00:15:34 +01:00

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: entraRoleDefinitions and entraRoleAssignments. All calls go through GraphClientInterface. No hardcoded endpoints.
  • 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.
  • 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).
  • Workspace isolation: StoredReports include workspace_id (NOT NULL). Findings derive workspace via DerivesWorkspaceIdFromTenant. Widget resolves tenant via Filament::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 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).
  • 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).
  • 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_roles added to centralized FindingTypeBadge mapper with BadgeSpec('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_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)

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:

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