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