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
201 lines
7.6 KiB
Markdown
201 lines
7.6 KiB
Markdown
# 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
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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)
|
|
6. **Threshold**: 5 (hardcoded in v1, `TODO` comment for future settings)
|
|
7. **Fingerprint**: `substr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64)`
|
|
8. **Severity**: `high`
|
|
9. **Evidence**: `{ count, threshold, principals: [{ display_name, type, id }] }`
|
|
10. **Auto-resolve**: When count drops to ≤ threshold, resolve with `reason=ga_count_within_threshold`
|
|
|
|
#### Auto-resolve stale findings
|
|
11. Query all open `entra_admin_roles` findings for the tenant
|
|
12. For each whose fingerprint is NOT in the current scan's computed fingerprints → `resolve('role_assignment_removed')`
|
|
|
|
#### Alert events
|
|
13. New or re-opened findings with severity `>= high` produce an alert event with `event_type=entra.admin_roles.high`
|
|
14. 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
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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()`
|
|
|
|
```php
|
|
public function getRequiredPermissions(): array
|
|
{
|
|
return array_merge(
|
|
config('intune_permissions.permissions', []),
|
|
config('entra_permissions.permissions', []),
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Modified: `EvaluateAlertsJob`
|
|
|
|
### New method
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
$events = [
|
|
...$this->highDriftEvents(/* ... */),
|
|
...$this->compareFailedEvents(/* ... */),
|
|
...$this->permissionMissingEvents(/* ... */),
|
|
...$this->entraAdminRolesHighEvents(/* ... */), // NEW
|
|
];
|
|
```
|