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