# 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 ]; ```