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
This commit is contained in:
Ahmed Darrazi 2026-02-22 00:15:34 +01:00
parent b6e376e875
commit d25290d95e
6 changed files with 903 additions and 147 deletions

View File

@ -54,8 +54,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,200 @@
# Internal Service Contracts: Entra Admin Roles Evidence + Findings (Spec 105)
**Date**: 2026-02-21 | **Branch**: `105-entra-admin-roles-evidence-findings`
## Service 1: `EntraAdminRolesReportService`
**Namespace**: `App\Services\EntraAdminRoles`
**Responsibility**: Fetch Entra directory role data from Graph, build a structured report payload, compute fingerprint, and persist as StoredReport (with deduplication).
### Interface
```php
interface EntraAdminRolesReportServiceContract
{
/**
* Fetch Graph data and produce a stored report for the given tenant.
*
* - Fetches roleDefinitions and roleAssignments from Graph
* - Builds payload per spec FR-005
* - Computes fingerprint from sorted (role_template_or_id, principal_id, scope_id) tuples
* - Creates StoredReport if fingerprint differs from latest report
* - Deduplicates if fingerprint matches latest report
*
* @throws \App\Exceptions\GraphConnectionException when Graph is unreachable
* @throws \App\Exceptions\MissingPermissionException when required permission is missing
*/
public function generate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResult;
}
```
### Dependencies
- `GraphClientInterface` — for Graph API calls
- `HighPrivilegeRoleCatalog` — for classifying which roles are high-privilege
- `StoredReport` model — for persistence
### Behavior Rules
1. **All-or-nothing**: If `roleDefinitions` fetch succeeds but `roleAssignments` fails, the entire generate() throws — no partial report.
2. **Fingerprint**: SHA-256 of sorted `"{role_template_or_id}:{principal_id}:{scope_id}"` tuples, joined by `\n`. This produces a deterministic hash regardless of Graph response ordering.
3. **Deduplication**: Before creating a report, check if the latest report for `(tenant_id, report_type=entra.admin_roles)` has the same fingerprint. If yes, return `created=false` with the existing report's ID.
4. **Previous fingerprint**: When creating a new report, set `previous_fingerprint` to the latest existing report's fingerprint (or null if first report).
---
## Service 2: `EntraAdminRolesFindingGenerator`
**Namespace**: `App\Services\EntraAdminRoles`
**Responsibility**: Generate, upsert, auto-resolve, and re-open findings based on report payload data.
### Interface
```php
interface EntraAdminRolesFindingGeneratorContract
{
/**
* Process the report payload and manage findings lifecycle.
*
* - Create finding per high-privilege (principal, role) pair
* - Auto-resolve findings for removed assignments
* - Re-open resolved findings for re-assigned roles
* - Generate aggregate "Too many GAs" finding when threshold exceeded
* - Produce alert events for new/re-opened findings
*/
public function generate(
Tenant $tenant,
array $reportPayload,
?OperationRun $operationRun = null,
): EntraAdminRolesFindingResult;
/**
* @return array<int, array<string, mixed>>
*/
public function getAlertEvents(): array;
}
```
### Behavior Rules
#### Individual findings (per principal per role)
1. **Fingerprint**: `substr(hash('sha256', "entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}"), 0, 64)`
2. **Subject**: `subject_type='role_assignment'`, `subject_external_id="{principal_id}:{role_definition_id}"`
3. **Severity**: `critical` for Global Administrator, `high` for all other high-privilege roles (from `HighPrivilegeRoleCatalog`)
4. **Evidence**: `{ role_display_name, principal_display_name, principal_type, principal_id, role_definition_id, role_template_id, directory_scope_id, is_built_in, measured_at }`
5. **Upsert**: Lookup by `[tenant_id, fingerprint]`. If found and open → update `evidence_jsonb`. If found and resolved → `reopen()`. If not found → create.
#### Aggregate finding (Too many Global Admins)
6. **Threshold**: 5 (hardcoded in v1, `TODO` comment for future settings)
7. **Fingerprint**: `substr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64)`
8. **Severity**: `high`
9. **Evidence**: `{ count, threshold, principals: [{ display_name, type, id }] }`
10. **Auto-resolve**: When count drops to ≤ threshold, resolve with `reason=ga_count_within_threshold`
#### Auto-resolve stale findings
11. Query all open `entra_admin_roles` findings for the tenant
12. For each whose fingerprint is NOT in the current scan's computed fingerprints → `resolve('role_assignment_removed')`
#### Alert events
13. New or re-opened findings with severity `>= high` produce an alert event with `event_type=entra.admin_roles.high`
14. Unchanged or resolved findings do NOT produce events
---
## Service 3: `HighPrivilegeRoleCatalog`
**Namespace**: `App\Services\EntraAdminRoles`
**Responsibility**: Classify Entra role definitions as high-privilege with severity.
### Interface
```php
final class HighPrivilegeRoleCatalog
{
/**
* Classify a role by template_id (preferred) or display_name (fallback).
*
* @return string|null Severity ('critical'|'high') or null if not high-privilege
*/
public function classify(string $templateIdOrId, ?string $displayName = null): ?string;
public function isHighPrivilege(string $templateIdOrId, ?string $displayName = null): bool;
public function isGlobalAdministrator(string $templateIdOrId, ?string $displayName = null): bool;
/**
* @return array<string, string> All template_id → severity mappings
*/
public function allTemplateIds(): array;
}
```
### Behavior Rules
1. Check `template_id` against the static catalog FIRST
2. If no match and `display_name` is provided, check case-insensitive display name fallback
3. Return null if neither matches (role is not high-privilege)
---
## Job: `ScanEntraAdminRolesJob`
**Namespace**: `App\Jobs`
**Responsibility**: Orchestrate the scan lifecycle: OperationRun management, report generation, finding generation.
### Constructor
```php
public function __construct(
public int $tenantId,
public int $workspaceId,
public ?int $initiatorUserId = null,
)
```
### Behavior Rules
1. Resolve tenant; if no active provider connection → return early (no OperationRun, no error)
2. Create/reuse OperationRun via `OperationRunService::ensureRunWithIdentity()` with type `entra.admin_roles.scan`
3. Call `EntraAdminRolesReportService::generate()` — get report result
4. If report was created (new data), call `EntraAdminRolesFindingGenerator::generate()` with the payload
5. If report was NOT created (no change), still call finding generator for auto-resolve (stale findings from removed assignments)
6. Record success/failure outcome on OperationRun with counts
7. On Graph error → record failure with sanitized error message, re-throw for retry
---
## Modified: `TenantPermissionService::getRequiredPermissions()`
```php
public function getRequiredPermissions(): array
{
return array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
}
```
---
## Modified: `EvaluateAlertsJob`
### New method
```php
private function entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
// Same pattern as highDriftEvents() and permissionMissingEvents()
// Query: finding_type=entra_admin_roles, status IN (new), severity IN (high, critical)
// updated_at > $windowStart
// Return: event_type=entra.admin_roles.high, fingerprint_key=finding:{id}
}
```
### handle() change
```php
$events = [
...$this->highDriftEvents(/* ... */),
...$this->compareFailedEvents(/* ... */),
...$this->permissionMissingEvents(/* ... */),
...$this->entraAdminRolesHighEvents(/* ... */), // NEW
];
```

View File

@ -0,0 +1,326 @@
# Data Model: Entra Admin Roles Evidence + Findings (Spec 105)
**Date**: 2026-02-21 | **Branch**: `105-entra-admin-roles-evidence-findings`
## Migration: Add fingerprint columns to `stored_reports`
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `fingerprint` | string(64) | YES | null | Content-based SHA-256 hash for deduplication |
| `previous_fingerprint` | string(64) | YES | null | References prior report's fingerprint for drift chain |
**New indexes**:
- Unique: `[tenant_id, report_type, fingerprint]` — prevents duplicate reports with identical content per tenant/type
- Index: `[tenant_id, report_type, created_at DESC]` — optimizes "latest report per type" queries
**Why nullable**: Existing `permission_posture` reports (Spec 104) don't use fingerprinting. The column must be nullable so existing data remains valid.
---
## No changes to `findings` table
The existing table already has all required columns. New values are used in existing string columns:
- `finding_type = 'entra_admin_roles'` (new value)
- `source = 'entra.admin_roles'` (new value)
- `subject_type = 'role_assignment'` (new value)
- Gravity, status, fingerprint, evidence_jsonb — all existing columns reused as-is
---
## No changes to `alert_rules` table
The `event_type` column is a string — `entra.admin_roles.high` is a new value, not a schema change.
---
## Modified Model: `StoredReport`
### New Constants
```php
const REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';
```
### New Fillable / Attributes
```php
protected $fillable = [
'workspace_id',
'tenant_id',
'report_type',
'payload',
'fingerprint', // NEW
'previous_fingerprint', // NEW
];
```
---
## Modified Model: `Finding`
### New Constants
```php
const FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
```
No method changes — existing `resolve()`, `reopen()`, and fingerprint-based lookup are reused.
---
## Modified Model: `AlertRule`
### New Constant
```php
const EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
```
---
## Modified Enum: `OperationRunType`
### New Case
```php
case EntraAdminRolesScan = 'entra.admin_roles.scan';
```
---
## Modified Class: `Capabilities`
### New Constants
```php
const ENTRA_ROLES_VIEW = 'entra_roles.view';
const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
```
---
## New Class: `HighPrivilegeRoleCatalog`
```php
namespace App\Services\EntraAdminRoles;
final class HighPrivilegeRoleCatalog
{
/**
* Template ID → severity mapping.
*
* @var array<string, string>
*/
private const CATALOG = [
'62e90394-69f5-4237-9190-012177145e10' => 'critical', // Global Administrator
'e8611ab8-c189-46e8-94e1-60213ab1f814' => 'high', // Privileged Role Administrator
'194ae4cb-b126-40b2-bd5b-6091b380977d' => 'high', // Security Administrator
'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' => 'high', // Conditional Access Administrator
'29232cdf-9323-42fd-ade2-1d097af3e4de' => 'high', // Exchange Administrator
'c4e39bd9-1100-46d3-8c65-fb160da0071f' => 'high', // Authentication Administrator
];
/**
* Display name fallback (case-insensitive) for roles without template_id.
*
* @var array<string, string>
*/
private const DISPLAY_NAME_FALLBACK = [
'global administrator' => 'critical',
'privileged role administrator' => 'high',
'security administrator' => 'high',
'conditional access administrator' => 'high',
'exchange administrator' => 'high',
'authentication administrator' => 'high',
];
public function classify(string $templateIdOrId, ?string $displayName): ?string;
// Returns severity ('critical'|'high') or null if not high-privilege
public function isHighPrivilege(string $templateIdOrId, ?string $displayName): bool;
public function isGlobalAdministrator(string $templateIdOrId, ?string $displayName): bool;
// Specifically checks for Global Administrator
}
```
---
## New Class: `EntraAdminRolesReportService`
```php
namespace App\Services\EntraAdminRoles;
final class EntraAdminRolesReportService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly HighPrivilegeRoleCatalog $catalog,
) {}
/**
* Fetch role data from Graph, build payload, create/deduplicate stored report.
*
* @return EntraAdminRolesReportResult
*/
public function generate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResult;
}
```
---
## New Class: `EntraAdminRolesFindingGenerator`
```php
namespace App\Services\EntraAdminRoles;
final class EntraAdminRolesFindingGenerator
{
public function __construct(
private readonly HighPrivilegeRoleCatalog $catalog,
) {}
/**
* Generate/upsert/auto-resolve findings based on report data.
*
* @param array $reportPayload The payload from the stored report
* @return EntraAdminRolesFindingResult
*/
public function generate(
Tenant $tenant,
array $reportPayload,
?OperationRun $operationRun = null,
): EntraAdminRolesFindingResult;
/**
* @return array<int, array<string, mixed>> Alert events produced during generation
*/
public function getAlertEvents(): array;
}
```
---
## New Value Object: `EntraAdminRolesReportResult`
```php
namespace App\Services\EntraAdminRoles;
final readonly class EntraAdminRolesReportResult
{
public function __construct(
public bool $created, // true if new report was created (not a dupe)
public ?int $storedReportId, // ID of the report (new or existing)
public string $fingerprint, // content fingerprint
public array $payload, // the full payload
) {}
}
```
---
## New Value Object: `EntraAdminRolesFindingResult`
```php
namespace App\Services\EntraAdminRoles;
final readonly class EntraAdminRolesFindingResult
{
public function __construct(
public int $created,
public int $resolved,
public int $reopened,
public int $unchanged,
public int $alertEventsProduced,
) {}
}
```
---
## New Job: `ScanEntraAdminRolesJob`
```php
namespace App\Jobs;
class ScanEntraAdminRolesJob implements ShouldQueue
{
public function __construct(
public int $tenantId,
public int $workspaceId,
public ?int $initiatorUserId = null,
) {}
// Creates/reuses OperationRun via OperationRunService
// Calls EntraAdminRolesReportService::generate()
// Calls EntraAdminRolesFindingGenerator::generate()
// Records outcome on OperationRun
}
```
---
## New Config: `config/entra_permissions.php`
```php
return [
'permissions' => [
[
'key' => 'RoleManagement.Read.Directory',
'type' => 'application',
'description' => 'Read directory role definitions and assignments for Entra admin roles evidence.',
'features' => ['entra-admin-roles'],
],
],
];
```
---
## New Graph Contracts (in `config/graph_contracts.php`)
```php
'entraRoleDefinitions' => [
'resource' => 'roleManagement/directory/roleDefinitions',
'allowed_select' => ['id', 'displayName', 'templateId', 'isBuiltIn'],
'allowed_expand' => [],
],
'entraRoleAssignments' => [
'resource' => 'roleManagement/directory/roleAssignments',
'allowed_select' => ['id', 'roleDefinitionId', 'principalId', 'directoryScopeId'],
'allowed_expand' => ['principal'],
],
```
---
## Modified Badge: `FindingTypeBadge`
Add mapping for `entra_admin_roles`:
```php
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec(
'Entra admin roles',
'danger',
'heroicon-m-identification',
),
```
---
## Entity Relationship Summary
```
Workspace 1──N Tenant 1──N StoredReport (report_type=entra.admin_roles)
1──N Finding (finding_type=entra_admin_roles)
1──N OperationRun (type=entra.admin_roles.scan)
Workspace 1──N AlertRule (event_type=entra.admin_roles.high)
1──N AlertDelivery
ScanEntraAdminRolesJob
├── creates OperationRun
├── calls EntraAdminRolesReportService → StoredReport
└── calls EntraAdminRolesFindingGenerator → Finding[] + AlertEvent[]
└── EvaluateAlertsJob → AlertDelivery[]
```

View File

@ -1,195 +1,219 @@
# Plan — 105 Entra Admin Roles Evidence + Findings
# Implementation Plan: Entra Admin Roles Evidence + Findings
**Feature Branch**: `105-entra-admin-roles-evidence-findings`
**Created**: 2026-02-21
**Depends on**: Spec 104 (stored_reports, posture patterns), Alerts v1 (099), Findings + OperationRuns
**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
## Phase 1 — Infrastructure & Permissions Registry
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.
### 1.1 Add Entra required permissions registry
## Technical Context
- Create `config/entra_permissions.php` following `config/intune_permissions.php` schema.
- Declare `RoleManagement.Read.Directory` (type: application, features: `['entra-admin-roles']`).
- Optionally: `Directory.Read.All` as fallback entry (type: application, features: `['entra-admin-roles']`).
**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
### 1.2 Merge registry loader
## Constitution Check
- Locate where `config('intune_permissions.permissions')` is loaded (TenantPermissionService or equivalent).
- Extend the loader to also read `config('entra_permissions.permissions')` and merge into the combined required-permissions list.
- Existing Intune posture flows must remain unchanged (no breaking change).
- Add test: registry merger returns combined set; existing tests still pass.
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
### 1.3 Register Graph endpoints in `config/graph_contracts.php`
- [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).
- Add entries for:
- `GET /roleManagement/directory/roleDefinitions`
- `GET /roleManagement/directory/roleAssignments?$expand=principal`
- Follow existing contract registration patterns (endpoint key, method, URI, scopes).
**Post-Phase-1 re-check**: All items pass. No violations found.
### 1.4 Add RBAC capabilities
## Project Structure
- Add to `App\Support\Auth\Capabilities`:
- `ENTRA_ROLES_VIEW = 'entra_roles.view'`
- `ENTRA_ROLES_MANAGE = 'entra_roles.manage'`
- Update role-to-capability mapping:
- Readonly/Operator → `ENTRA_ROLES_VIEW`
- Manager/Owner → `ENTRA_ROLES_VIEW` + `ENTRA_ROLES_MANAGE`
### Documentation (this feature)
### 1.5 Add OperationRunType case
```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)
```
- Add `EntraAdminRolesScan = 'entra.admin_roles.scan'` to `App\Support\OperationRunType` enum.
### 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)
## Phase 2 — Core Services
config/
├── entra_permissions.php # NEW: Entra permission registry
└── graph_contracts.php # MODIFIED: +entraRoleDefinitions, +entraRoleAssignments
### 2.1 Implement `HighPrivilegeRoleCatalog`
database/
├── migrations/
│ └── XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns
└── factories/
└── FindingFactory.php # MODIFIED: +entraAdminRoles() state
- Create `App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog`.
- Hardcoded mapping of template_id → (display_name, severity).
- Public methods:
- `isHighPrivilege(string $templateId, ?string $displayName): bool`
- `severityFor(string $templateId, ?string $displayName): ?string`
- `allTemplateIds(): array`
- `globalAdminTemplateId(): string`
- Fallback: if `template_id` not in catalog, check `display_name`.
- Full unit test coverage.
resources/views/filament/widgets/tenant/
└── admin-roles-summary.blade.php # NEW: card template
### 2.2 Implement `EntraAdminRolesReportService`
routes/
└── console.php # MODIFIED: schedule daily admin roles scan
- Create `App\Services\EntraAdminRoles\EntraAdminRolesReportService`.
- Responsibilities:
1. Fetch `roleDefinitions` via Graph (using registered contract).
2. Fetch `roleAssignments?$expand=principal` via Graph.
3. Build payload per spec (role_definitions, role_assignments, totals, high_privilege).
4. Compute fingerprint: SHA-256 of sorted `(role_template_or_id, principal_id, scope_id)` tuples.
5. Determine `previous_fingerprint` from latest existing report for this tenant.
6. Dedupe: if current fingerprint == latest report's fingerprint, skip creation.
7. Create `StoredReport` with `report_type=entra.admin_roles`.
- Add `REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles'` constant to `StoredReport` model.
- Tests: report creation, dedupe, fingerprint computation, previous_fingerprint chaining.
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
```
### 2.3 Implement `EntraAdminRolesFindingGenerator`
**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.
- Create `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`.
- Responsibilities:
1. Accept report payload (or structured DTO).
2. For each role assignment where role is high-privilege (per `HighPrivilegeRoleCatalog`):
- Compute fingerprint: `entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}`
- Upsert finding: `finding_type=entra_admin_roles`, `source=entra.admin_roles`
- Set severity per catalog (critical for GA, high otherwise)
- Populate `evidence_jsonb` with role, principal, scope details
- Update `times_seen`, `last_seen_at` on existing findings
3. Aggregate check: count GA principals > threshold (5) → aggregate finding with distinct fingerprint.
4. Auto-resolve: query open findings for this tenant+source not in current scan's fingerprint set → resolve.
5. Re-open: if a resolved finding's fingerprint matches current data → set status=new, clear resolved fields.
6. Collect alert events for new/re-opened findings.
- Add `FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles'` constant to `Finding` model.
- Tests: creation, idempotency, auto-resolve, re-open, aggregate finding, alert events.
## Complexity Tracking
---
> No constitution violations. No complexity justifications needed.
## Phase 3 — Job & Scheduling
## Implementation Phases
### 3.1 Implement `ScanEntraAdminRolesJob`
### Phase A — Foundation (Migration + Constants + Config)
- Create `App\Jobs\EntraAdminRoles\ScanEntraAdminRolesJob` (implements `ShouldQueue`).
- Lifecycle:
1. Check tenant has provider connection; skip if not.
2. Create OperationRun (`entra.admin_roles.scan`, status=running).
3. Call `EntraAdminRolesReportService::generate()`.
4. Call `EntraAdminRolesFindingGenerator::generate()`.
5. Dispatch alert events via `AlertDispatchService`.
6. Mark OperationRun completed (success/failure with error details).
- Active-run uniqueness: check for existing running OperationRun before starting.
- Tests: job dispatching, OperationRun lifecycle, skip on no connection, failure handling.
**Goal**: Establish the data layer, config, and constants that all other phases depend on.
### 3.2 Add to workspace dispatcher
**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
- Register `ScanEntraAdminRolesJob` in the daily workspace dispatch schedule.
- Same pattern as existing scheduled scans: iterate tenants with active connections, dispatch per tenant.
**Dependencies**: None (foundation layer).
---
### Phase B — High-Privilege Catalog + Report Service
## Phase 4 — Alerts Integration
**Goal**: Implement role classification and the Graph-backed report generation service.
### 4.1 Add alert event type
**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)
- Add constant `EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high'` to `AlertRule` model.
- Add to event type options array (used in alert rule form dropdown).
**Dependencies**: Phase A (constants, config, migration).
### 4.2 Extend `EvaluateAlertsJob`
### Phase C — Finding Generator
- Add a new producer section in `EvaluateAlertsJob` that:
- Queries findings where `source=entra.admin_roles`, `severity>=high`, `status` in (`new`).
- Produces events with `event_type=entra.admin_roles.high`.
- Uses finding fingerprint as `fingerprint_key` for cooldown/dedupe.
- Tests: alert rule matching, delivery creation, cooldown.
**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
## Phase 5 — Badge Catalog
**Dependencies**: Phase B (catalog, report service provides payload structure).
### 5.1 Add `entra_admin_roles` to `FindingTypeBadge`
### Phase D — Scan Job + Scheduling + Permission Posture Integration
- Extend `App\Support\Badges\Domains\FindingTypeBadge` mapper with:
- `Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-shield-exclamation')`
- Test: badge renders correctly for new type.
**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
## Phase 6 — UI
**Dependencies**: Phase C (finding generator).
### 6.1 Tenant card widget "Admin Roles"
### Phase E — Alerts Integration
- Create a Livewire/Filament widget for the tenant dashboard.
- Displays: last scan timestamp, high-privilege assignment count.
- CTA: "Scan now" (visible only if user has `ENTRA_ROLES_MANAGE`).
- Link: "View latest report" (stored reports viewer filtered by `entra.admin_roles`).
- Empty state: "No scan performed" + gated "Scan now" CTA.
**Goal**: Connect admin roles findings to the existing alert pipeline.
### 6.2 Report viewer enhancements
**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
- Extend existing stored reports viewer to handle `report_type=entra.admin_roles`:
- Summary section showing totals.
- Table of high-privilege role assignments (principal display name, type, role, scope).
- Filter by `report_type` on report listing.
**Dependencies**: Phase D (job produces findings that generate alert events).
---
### Phase F — Tenant Dashboard Widget
## Phase 7 — Tests
**Goal**: Provide a tenant dashboard card for admin roles posture at-a-glance.
### 7.1 Unit tests
**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`
- `HighPrivilegeRoleCatalog`: classification by template_id, fallback to display_name, unknown role returns false.
- `EntraAdminRolesReportService`: report creation, fingerprint computation, dedupe, previous_fingerprint.
- `EntraAdminRolesFindingGenerator`: finding creation, severity assignment, idempotent upsert, auto-resolve, re-open, aggregate finding.
- Registry merge: combined Intune + Entra permissions.
**Dependencies**: Phases A-D complete (job, reports, constants).
### 7.2 Feature tests
## Filament v5 Agent Output Contract
- `ScanEntraAdminRolesJob`: full lifecycle (report + findings + alerts), skip unconnected, failure handling.
- Alerts: event type matching, delivery creation, cooldown.
- RBAC: `ENTRA_ROLES_VIEW` can view; `ENTRA_ROLES_MANAGE` can scan; missing capability → 403; non-member → 404.
### 7.3 Posture integration smoke test
- With Entra permissions in merged registry, posture generator includes them in score calculation.
### 7.4 Run full suite
- `vendor/bin/sail artisan test --compact` — all tests green.
- `vendor/bin/sail bin pint --dirty` — code style clean.
---
## Filament v5 Compliance Notes
1. **Livewire v4.0+**: All widget components are Livewire v4 compatible.
2. **Provider registration**: No new panel providers — existing admin panel in `bootstrap/providers.php`.
3. **Global search**: No new globally searchable resources.
4. **Destructive actions**: "Scan now" is non-destructive (read-only Graph call); no `requiresConfirmation()` needed.
5. **Asset strategy**: No new heavy assets. Widget uses standard Filament components.
6. **Testing plan**: Widget tested as Livewire component; finding generator + report service unit tested; job feature tested; RBAC positive/negative tests included.
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).

View File

@ -0,0 +1,90 @@
# Quickstart: Entra Admin Roles Evidence + Findings (Spec 105)
**Branch**: `105-entra-admin-roles-evidence-findings`
## Prerequisites
- Laravel Sail running (`vendor/bin/sail up -d`)
- Database migrated (`vendor/bin/sail artisan migrate`)
- At least one tenant with a provider connection configured
- Spec 104 (Provider Permission Posture) deployed (`stored_reports` table exists)
## New Files Created (Implementation Order)
### Phase A — Foundation (Config + Migration + Constants)
```
config/entra_permissions.php # NEW: Entra permission registry
database/migrations/XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns
```
### Phase B — Catalog + Report Service
```
app/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.php # NEW: role classification
app/Services/EntraAdminRoles/EntraAdminRolesReportResult.php # NEW: value object
app/Services/EntraAdminRoles/EntraAdminRolesReportService.php # NEW: Graph fetch + report creation
```
### Phase C — Finding Generator
```
app/Services/EntraAdminRoles/EntraAdminRolesFindingResult.php # NEW: value object
app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php # NEW: findings lifecycle
```
### Phase D — Job + Scheduling
```
app/Jobs/ScanEntraAdminRolesJob.php # NEW: orchestrator job
```
### Phase E — UI Widget
```
app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php # NEW: dashboard card
resources/views/filament/widgets/tenant/admin-roles-summary.blade.php # NEW: card template
```
## Modified Files Summary
| File | Change |
|------|--------|
| `app/Models/StoredReport.php` | Add `REPORT_TYPE_ENTRA_ADMIN_ROLES` constant, add `fingerprint`+`previous_fingerprint` to fillable |
| `app/Models/Finding.php` | Add `FINDING_TYPE_ENTRA_ADMIN_ROLES` constant |
| `app/Models/AlertRule.php` | Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` constant |
| `app/Support/OperationRunType.php` | Add `EntraAdminRolesScan` case |
| `app/Support/Auth/Capabilities.php` | Add `ENTRA_ROLES_VIEW`, `ENTRA_ROLES_MANAGE` constants |
| `app/Support/Badges/Domains/FindingTypeBadge.php` | Add `entra_admin_roles` badge mapping |
| `app/Services/Intune/TenantPermissionService.php` | Merge `entra_permissions.php` into `getRequiredPermissions()` |
| `app/Jobs/Alerts/EvaluateAlertsJob.php` | Add `entraAdminRolesHighEvents()` method + call in `handle()` |
| `config/graph_contracts.php` | Add `entraRoleDefinitions` + `entraRoleAssignments` type entries |
| `app/Support/Auth/RoleCapabilityMap.php` (or equivalent) | Map new capabilities to roles |
| `app/Filament/Resources/AlertRuleResource.php` | Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` to event type dropdown |
| `routes/console.php` | Register daily scan schedule |
| `database/factories/FindingFactory.php` | Add `entraAdminRoles()` state |
## Running Tests
```bash
# All Spec 105 tests
vendor/bin/sail artisan test --compact --filter=EntraAdminRoles
# Specific test files
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/HighPrivilegeRoleCatalogTest.php
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php
```
## Key Design Decisions
1. **Fingerprint on stored_reports**: Migration adds nullable columns — existing permission_posture reports unaffected
2. **HighPrivilegeRoleCatalog**: Static PHP catalog using Microsoft template_ids (GUIDs), extensible later via workspace settings
3. **Finding per (principal, role)**: One finding per role assignment, not per principal — enables precise auto-resolve
4. **Registry merge**: `TenantPermissionService::getRequiredPermissions()` now merges both `intune_permissions.php` and `entra_permissions.php`
5. **Alert integration**: Same pattern as existing alert event producers — new method in `EvaluateAlertsJob`
6. **Widget not Resource**: Admin Roles card is a widget on the tenant dashboard, not a full CRUD resource
7. **RBAC boundary**: `ENTRA_ROLES_VIEW` gates card+report only; findings use existing `FINDINGS_VIEW`

View File

@ -0,0 +1,116 @@
# Research: Entra Admin Roles Evidence + Findings (Spec 105)
**Date**: 2026-02-21 | **Branch**: `105-entra-admin-roles-evidence-findings`
## R1 — StoredReport Fingerprint + Deduplication
**Decision**: Add `fingerprint` and `previous_fingerprint` columns to `stored_reports` via a new migration.
**Rationale**: The spec requires content-based deduplication — if the role assignment data is identical between scans, no new report should be created. The existing `stored_reports` table (Spec 104) has no fingerprint columns. Spec 104's permission posture reports don't use fingerprinting (they always create a new report per compare run). Spec 105 introduces a different pattern: only store a new report when data has changed.
**Migration**:
- Add `fingerprint` (string(64), nullable) — SHA-256 of sorted role assignment tuples
- Add `previous_fingerprint` (string(64), nullable) — references the prior report's fingerprint for drift chain
- Add unique index on `[tenant_id, report_type, fingerprint]` to enforce deduplication at DB level
- Nullable because existing permission_posture reports have no fingerprint
**Alternatives considered**:
- Fingerprint as payload field (not a column): Rejected because DB-level uniqueness constraint prevents race conditions
- Separate table for Entra reports: Rejected because it fragments the report model unnecessarily
## R2 — Graph API Pattern: roleManagement/directory
**Decision**: Register two new endpoints in `config/graph_contracts.php` under a new `entraRoleDefinitions` and `entraRoleAssignments` type section. Graph calls go through `GraphClientInterface`.
**Rationale**: The constitution requires all Graph calls to go through the contract registry. The unified RBAC API uses `GET /roleManagement/directory/roleDefinitions` and `GET /roleManagement/directory/roleAssignments?$expand=principal`. These are read-only, paginated endpoints returning standard Graph collections.
**Implementation**:
- New graph_contracts entry: `entraRoleDefinitions``roleManagement/directory/roleDefinitions`
- New graph_contracts entry: `entraRoleAssignments``roleManagement/directory/roleAssignments`, with `allowed_expand: ['principal']`
- The service uses `GraphClientInterface::getCollection()` (or equivalent paginated reader) to fetch all results
**Pagination**: Both endpoints return paginated results (standard `@odata.nextLink`). The existing paginated Graph reader pattern handles this.
## R3 — HighPrivilegeRoleCatalog Classification Strategy
**Decision**: Pure PHP class with a static catalog mapping `template_id` → severity. Fallback to `display_name` matching when `template_id` is null (custom roles).
**Rationale**: Microsoft documents stable `template_id` values for built-in roles. These are GUIDs that don't change across tenants. Using `template_id` as primary key ensures cross-tenant consistency. Display name fallback handles edge cases where `template_id` is null.
**v1 Catalog** (from spec):
| Role | Template ID | Severity |
|------|------------|----------|
| Global Administrator | `62e90394-69f5-4237-9190-012177145e10` | critical |
| Privileged Role Administrator | `e8611ab8-c189-46e8-94e1-60213ab1f814` | high |
| Security Administrator | `194ae4cb-b126-40b2-bd5b-6091b380977d` | high |
| Conditional Access Administrator | `b1be1c3e-b65d-4f19-8427-f6fa0d97feb9` | high |
| Exchange Administrator | `29232cdf-9323-42fd-ade2-1d097af3e4de` | high |
| Authentication Administrator | `c4e39bd9-1100-46d3-8c65-fb160da0071f` | high |
**Extensibility**: The catalog class is a standalone value object — future specs can make this configurable per workspace via settings. v1 is hardcoded.
## R4 — Finding Fingerprint Formula
**Decision**: `entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}` hashed to SHA-256 (64 chars).
**Rationale**: This matches the existing pattern from `PermissionPostureFindingGenerator` which uses `permission_posture:{tenant_id}:{permission_key}`. Including all four dimensions (tenant, role, principal, scope) ensures uniqueness even if the same principal holds the same role at different scopes.
**Aggregate finding** (Too many GAs): `entra_admin_role_ga_count:{tenant_id}` — single fingerprint per tenant for the threshold finding.
**Alternatives considered**:
- Including `assignment_id` from Graph: Rejected because assignment IDs can change when role assignments are recreated, causing false re-opens
## R5 — Entra Permissions Registry Merge Strategy
**Decision**: Modify `TenantPermissionService::getRequiredPermissions()` to merge `config/intune_permissions.php` and `config/entra_permissions.php`.
**Rationale**: The current implementation at L23 hardcodes `config('intune_permissions.permissions', [])`. The merge must:
1. Load both registries
2. Concatenate permission arrays
3. Return the merged set
This is a minimal change (2-3 lines). The posture generator and compare logic are generic — they iterate the returned permissions array regardless of source.
**Non-breaking guarantee**: Existing Intune permissions appear unchanged in the merged array. Posture scores recompute to include Entra permissions proportionally.
## R6 — Alert Event Integration
**Decision**: Add `entraAdminRolesHighEvents()` method to `EvaluateAlertsJob`, following the exact same pattern as `highDriftEvents()` and `permissionMissingEvents()`.
**Rationale**: The existing alert evaluation job collects events from dedicated methods and dispatches them. Adding one more method that queries `finding_type=entra_admin_roles` with `status=new` and severity `>= high` within the time window follows the established pattern exactly.
**Event structure**: Same shape as existing events (`event_type`, `tenant_id`, `severity`, `fingerprint_key`, `title`, `body`, `metadata`). The `fingerprint_key` uses `finding:{finding_id}` for cooldown dedup.
## R7 — OperationRunType for Scan
**Decision**: Add `EntraAdminRolesScan = 'entra.admin_roles.scan'` to the `OperationRunType` enum.
**Rationale**: The existing enum has cases like `InventorySync`, `PolicySync`, etc. The dot-notation value (`entra.admin_roles.scan`) follows the pattern of `policy.sync`, `policy.sync_one`, etc. Active-run uniqueness is enforced per `(workspace_id, tenant_id, run_type)` via `OperationRunService::ensureRunWithIdentity()`.
## R8 — Tenant Dashboard Widget Pattern
**Decision**: New `AdminRolesSummaryWidget` extending `Filament\Widgets\Widget` with a custom Blade view, matching the existing `RecentOperationsSummary` widget pattern.
**Rationale**: Existing tenant dashboard widgets (`RecentOperationsSummary`, `TenantVerificationReport`) use the same pattern: extend `Widget`, resolve the tenant from Filament context, query data, return view data. The Admin Roles card follows this exact pattern:
1. Resolve tenant via `Filament::getTenant()`
2. Query latest `stored_report` where `report_type=entra.admin_roles`
3. Extract summary stats (timestamp, high-privilege count)
4. Return view data to Blade template
5. "Scan now" action dispatches job (gated by `ENTRA_ROLES_MANAGE`)
**RBAC**: The widget is gated by `ENTRA_ROLES_VIEW` via `canView()` method or `static::canView()`. The "Scan now" button checks `ENTRA_ROLES_MANAGE` separately.
## R9 — No New Migration for Findings Table
**Decision**: No migration needed for the `findings` table.
**Rationale**: The existing `findings` table already has all required columns: `finding_type`, `source`, `scope_key`, `fingerprint` (unique per tenant), `subject_type`, `subject_external_id`, `severity`, `status`, `evidence_jsonb`, `resolved_at`, `resolved_reason`. Adding `finding_type=entra_admin_roles` is a new value in an existing string column — no schema change.
## R10 — Scan Scheduling Pattern
**Decision**: Daily scheduled dispatch following existing workspace dispatcher pattern.
**Rationale**: The spec mentions "daily via workspace dispatcher alongside existing scheduled scans." The existing pattern in `routes/console.php` schedules a command that iterates workspaces and dispatches per-tenant jobs. The admin roles scan follows this same dispatching loop.
**Implementation**: Either extend the existing dispatcher or add a `ScheduleEntraAdminRolesScanCommand` that iterates workspaces → tenants with active connections → dispatches `ScanEntraAdminRolesJob` per tenant. On-demand scan uses the same job, dispatched from the widget action.