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
7.6 KiB
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 callsHighPrivilegeRoleCatalog— for classifying which roles are high-privilegeStoredReportmodel — for persistence
Behavior Rules
- All-or-nothing: If
roleDefinitionsfetch succeeds butroleAssignmentsfails, the entire generate() throws — no partial report. - 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. - Deduplication: Before creating a report, check if the latest report for
(tenant_id, report_type=entra.admin_roles)has the same fingerprint. If yes, returncreated=falsewith the existing report's ID. - Previous fingerprint: When creating a new report, set
previous_fingerprintto 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)
- Fingerprint:
substr(hash('sha256', "entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}"), 0, 64) - Subject:
subject_type='role_assignment',subject_external_id="{principal_id}:{role_definition_id}" - Severity:
criticalfor Global Administrator,highfor all other high-privilege roles (fromHighPrivilegeRoleCatalog) - 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 } - Upsert: Lookup by
[tenant_id, fingerprint]. If found and open → updateevidence_jsonb. If found and resolved →reopen(). If not found → create.
Aggregate finding (Too many Global Admins)
- Threshold: 5 (hardcoded in v1,
TODOcomment for future settings) - Fingerprint:
substr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64) - Severity:
high - Evidence:
{ count, threshold, principals: [{ display_name, type, id }] } - Auto-resolve: When count drops to ≤ threshold, resolve with
reason=ga_count_within_threshold
Auto-resolve stale findings
- Query all open
entra_admin_rolesfindings for the tenant - For each whose fingerprint is NOT in the current scan's computed fingerprints →
resolve('role_assignment_removed')
Alert events
- New or re-opened findings with severity
>= highproduce an alert event withevent_type=entra.admin_roles.high - 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
- Check
template_idagainst the static catalog FIRST - If no match and
display_nameis provided, check case-insensitive display name fallback - 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
- Resolve tenant; if no active provider connection → return early (no OperationRun, no error)
- Create/reuse OperationRun via
OperationRunService::ensureRunWithIdentity()with typeentra.admin_roles.scan - Call
EntraAdminRolesReportService::generate()— get report result - If report was created (new data), call
EntraAdminRolesFindingGenerator::generate()with the payload - If report was NOT created (no change), still call finding generator for auto-resolve (stale findings from removed assignments)
- Record success/failure outcome on OperationRun with counts
- 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
];