TenantAtlas/specs/105-entra-admin-roles-evidence-findings/data-model.md
Ahmed Darrazi d25290d95e plan: spec 105 — Entra Admin Roles Evidence + Findings
Phase 0 research (R1-R10) + Phase 1 design artifacts:
- research.md: 10 decisions (fingerprint migration, Graph API, catalog, alerts)
- data-model.md: stored_reports migration, model/enum changes, new classes
- contracts/internal-services.md: 3 service + job contracts
- quickstart.md: implementation guide with file list + test commands
- plan.md: 6-phase implementation plan (A-F) with constitution check

Agent context: copilot-instructions.md updated
2026-02-22 00:15:34 +01: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[]