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