TenantAtlas/specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.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

7.6 KiB

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

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

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)

  1. Threshold: 5 (hardcoded in v1, TODO comment for future settings)
  2. Fingerprint: substr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64)
  3. Severity: high
  4. Evidence: { count, threshold, principals: [{ display_name, type, id }] }
  5. Auto-resolve: When count drops to ≤ threshold, resolve with reason=ga_count_within_threshold

Auto-resolve stale findings

  1. Query all open entra_admin_roles findings for the tenant
  2. For each whose fingerprint is NOT in the current scan's computed fingerprints → resolve('role_assignment_removed')

Alert events

  1. New or re-opened findings with severity >= high produce an alert event with event_type=entra.admin_roles.high
  2. 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

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

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()

public function getRequiredPermissions(): array
{
    return array_merge(
        config('intune_permissions.permissions', []),
        config('entra_permissions.permissions', []),
    );
}

Modified: EvaluateAlertsJob

New method

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

$events = [
    ...$this->highDriftEvents(/* ... */),
    ...$this->compareFailedEvents(/* ... */),
    ...$this->permissionMissingEvents(/* ... */),
    ...$this->entraAdminRolesHighEvents(/* ... */),  // NEW
];