diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 5887a2c..a16406e 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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 diff --git a/specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md b/specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md new file mode 100644 index 0000000..e70452b --- /dev/null +++ b/specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md @@ -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> + */ + 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 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 +]; +``` diff --git a/specs/105-entra-admin-roles-evidence-findings/data-model.md b/specs/105-entra-admin-roles-evidence-findings/data-model.md new file mode 100644 index 0000000..7cbff60 --- /dev/null +++ b/specs/105-entra-admin-roles-evidence-findings/data-model.md @@ -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 + */ + 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 + */ + 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> 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[] +``` diff --git a/specs/105-entra-admin-roles-evidence-findings/plan.md b/specs/105-entra-admin-roles-evidence-findings/plan.md index 633ca76..c462406 100644 --- a/specs/105-entra-admin-roles-evidence-findings/plan.md +++ b/specs/105-entra-admin-roles-evidence-findings/plan.md @@ -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). diff --git a/specs/105-entra-admin-roles-evidence-findings/quickstart.md b/specs/105-entra-admin-roles-evidence-findings/quickstart.md new file mode 100644 index 0000000..dac7811 --- /dev/null +++ b/specs/105-entra-admin-roles-evidence-findings/quickstart.md @@ -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` diff --git a/specs/105-entra-admin-roles-evidence-findings/research.md b/specs/105-entra-admin-roles-evidence-findings/research.md new file mode 100644 index 0000000..0454615 --- /dev/null +++ b/specs/105-entra-admin-roles-evidence-findings/research.md @@ -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.