TenantAtlas/specs/105-entra-admin-roles-evidence-findings/data-model.md
ahmido 6a15fe978a feat: Spec 105 — Entra Admin Roles Evidence + Findings (#128)
## Summary

Automated scanning of Entra ID directory roles to surface high-privilege role assignments as trackable findings with alerting support.

## What's included

### Core Services
- **EntraAdminRolesReportService** — Fetches role definitions + assignments via Graph API, builds payload with fingerprint deduplication
- **EntraAdminRolesFindingGenerator** — Creates/resolves/reopens findings based on high-privilege role catalog
- **HighPrivilegeRoleCatalog** — Curated list of high-privilege Entra roles (Global Admin, Privileged Auth Admin, etc.)
- **ScanEntraAdminRolesJob** — Queued job orchestrating scan → report → findings → alerts pipeline

### UI
- **AdminRolesSummaryWidget** — Tenant dashboard card showing last scan time, high-privilege assignment count, scan trigger button
- RBAC-gated: `ENTRA_ROLES_VIEW` for viewing, `ENTRA_ROLES_MANAGE` for scan trigger

### Infrastructure
- Graph contracts for `entraRoleDefinitions` + `entraRoleAssignments`
- `config/entra_permissions.php` — Entra permission registry
- `StoredReport.fingerprint` migration (deduplication support)
- `OperationCatalog` label + duration for `entra.admin_roles.scan`
- Artisan command `entra:scan-admin-roles` for CLI/scheduled use

### Global UX improvement
- **SummaryCountsNormalizer**: Zero values filtered, snake_case keys humanized (e.g. `report_deduped: 1` → `Report deduped: 1`). Affects all operation notifications.

## Test Coverage
- **12 test files**, **79+ tests**, **307+ assertions**
- Report service, finding generator, job orchestration, widget rendering, alert integration, RBAC enforcement, badge mapping

## Spec artifacts
- `specs/105-entra-admin-roles-evidence-findings/tasks.md` — Full task breakdown (38 tasks, all complete)
- `specs/105-entra-admin-roles-evidence-findings/checklists/requirements.md` — All items checked

## Files changed
46 files changed, 3641 insertions(+), 15 deletions(-)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #128
2026-02-22 02:37:36 +00:00

8.4 KiB

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

const REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';

New Fillable / Attributes

protected $fillable = [
    'workspace_id',
    'tenant_id',
    'report_type',
    'payload',
    'fingerprint',           // NEW
    'previous_fingerprint',  // NEW
];

Modified Model: Finding

New Constants

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

const EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';

Modified Enum: OperationRunType

New Case

case EntraAdminRolesScan = 'entra.admin_roles.scan';

Modified Class: Capabilities

New Constants

const ENTRA_ROLES_VIEW = 'entra_roles.view';
const ENTRA_ROLES_MANAGE = 'entra_roles.manage';

New Class: HighPrivilegeRoleCatalog

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

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

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

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

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

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

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)

'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:

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