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
This commit is contained in:
parent
b6e376e875
commit
d25290d95e
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -54,8 +54,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
|
||||
- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
# 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
|
||||
];
|
||||
```
|
||||
326
specs/105-entra-admin-roles-evidence-findings/data-model.md
Normal file
326
specs/105-entra-admin-roles-evidence-findings/data-model.md
Normal file
@ -0,0 +1,326 @@
|
||||
# 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
|
||||
|
||||
```php
|
||||
const REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';
|
||||
```
|
||||
|
||||
### New Fillable / Attributes
|
||||
|
||||
```php
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'tenant_id',
|
||||
'report_type',
|
||||
'payload',
|
||||
'fingerprint', // NEW
|
||||
'previous_fingerprint', // NEW
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modified Model: `Finding`
|
||||
|
||||
### New Constants
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
const EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modified Enum: `OperationRunType`
|
||||
|
||||
### New Case
|
||||
|
||||
```php
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modified Class: `Capabilities`
|
||||
|
||||
### New Constants
|
||||
|
||||
```php
|
||||
const ENTRA_ROLES_VIEW = 'entra_roles.view';
|
||||
const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Class: `HighPrivilegeRoleCatalog`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```php
|
||||
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`
|
||||
|
||||
```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`)
|
||||
|
||||
```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`:
|
||||
|
||||
```php
|
||||
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[]
|
||||
```
|
||||
@ -1,195 +1,219 @@
|
||||
# Plan — 105 Entra Admin Roles Evidence + Findings
|
||||
# Implementation Plan: Entra Admin Roles Evidence + Findings
|
||||
|
||||
**Feature Branch**: `105-entra-admin-roles-evidence-findings`
|
||||
**Created**: 2026-02-21
|
||||
**Depends on**: Spec 104 (stored_reports, posture patterns), Alerts v1 (099), Findings + OperationRuns
|
||||
**Branch**: `105-entra-admin-roles-evidence-findings` | **Date**: 2026-02-21 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/105-entra-admin-roles-evidence-findings/spec.md`
|
||||
|
||||
---
|
||||
## Summary
|
||||
|
||||
## Phase 1 — Infrastructure & Permissions Registry
|
||||
Implement an Entra Admin Roles evidence and findings pipeline that scans a tenant's directory role definitions and active role assignments via Microsoft Graph, persists results as fingerprinted stored reports, generates severity-classified findings for high-privilege assignments, integrates with the existing alerts pipeline, adds a tenant dashboard widget, and extends the permission posture with Entra-specific Graph permissions. The implementation reuses infrastructure from Spec 104 (StoredReports, fingerprint-based dedup, posture pattern) and the existing findings/alerts framework.
|
||||
|
||||
### 1.1 Add Entra required permissions registry
|
||||
## Technical Context
|
||||
|
||||
- Create `config/entra_permissions.php` following `config/intune_permissions.php` schema.
|
||||
- Declare `RoleManagement.Read.Directory` (type: application, features: `['entra-admin-roles']`).
|
||||
- Optionally: `Directory.Read.All` as fallback entry (type: application, features: `['entra-admin-roles']`).
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4
|
||||
**Storage**: PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
|
||||
**Target Platform**: Linux server (Docker/Sail locally, Dokploy for staging/production)
|
||||
**Project Type**: Web application (Laravel monolith)
|
||||
**Performance Goals**: Single tenant scan completes within 30s for up to 200 role assignments; alert delivery queued within 2 mins of finding creation
|
||||
**Constraints**: No external API calls from finding generator (reads report payload); all Graph calls via contract path; all work is queued
|
||||
**Scale/Scope**: Up to ~50 tenants per workspace, ~200 role assignments per tenant, ~6 high-privilege role types in v1 catalog
|
||||
|
||||
### 1.2 Merge registry loader
|
||||
## Constitution Check
|
||||
|
||||
- Locate where `config('intune_permissions.permissions')` is loaded (TenantPermissionService or equivalent).
|
||||
- Extend the loader to also read `config('entra_permissions.permissions')` and merge into the combined required-permissions list.
|
||||
- Existing Intune posture flows must remain unchanged (no breaking change).
|
||||
- Add test: registry merger returns combined set; existing tests still pass.
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
### 1.3 Register Graph endpoints in `config/graph_contracts.php`
|
||||
- [x] **Inventory-first**: Admin roles scan captures "last observed" active role assignments from Graph. StoredReports are immutable evidence snapshots, not configuration. Findings represent current posture analysis on observed data.
|
||||
- [x] **Read/write separation**: Scan is 100% read-only against Entra (no writes). StoredReports are immutable. Findings are system-generated — no manual create/edit. No preview/dry-run needed (non-destructive).
|
||||
- [x] **Graph contract path**: Two new Graph endpoints registered in `config/graph_contracts.php`: `entraRoleDefinitions` and `entraRoleAssignments`. All calls go through `GraphClientInterface`. No hardcoded endpoints.
|
||||
- [x] **Deterministic capabilities**: High-privilege classification is deterministic via `HighPrivilegeRoleCatalog` (static `template_id` → severity map). Severity derivation is testable via unit tests. New capabilities `ENTRA_ROLES_VIEW` and `ENTRA_ROLES_MANAGE` added to canonical `Capabilities` registry.
|
||||
- [x] **RBAC-UX**: Tenant-context routes remain tenant-scoped. Non-member → 404. Member without `ENTRA_ROLES_VIEW` → 403 for Admin Roles card/report. Member without `ENTRA_ROLES_MANAGE` → 403 for scan trigger. Findings list uses existing `FINDINGS_VIEW` (no dual-gate). Capabilities referenced via constants only (no raw strings).
|
||||
- [x] **Workspace isolation**: StoredReports include `workspace_id` (NOT NULL). Findings derive workspace via `DerivesWorkspaceIdFromTenant`. Widget resolves tenant via `Filament::getTenant()`. Non-member workspace access → 404.
|
||||
- [x] **RBAC-UX (destructive confirmation)**: No destructive actions. "Scan now" is a non-destructive read-only operation — no confirmation required.
|
||||
- [x] **RBAC-UX (global search)**: No new globally searchable resources. Existing search behavior unchanged.
|
||||
- [x] **Tenant isolation**: All findings, stored reports, and operation runs are scoped via `tenant_id` (NOT NULL). Cross-tenant access impossible at query level. Widget renders only current tenant data.
|
||||
- [x] **Run observability**: Each scan execution tracked as `OperationRun` with `type=entra.admin_roles.scan`. Start surfaces enqueue only (`ScanEntraAdminRolesJob` dispatched). OperationRun records status, timestamps, counts, and failures. Active-run uniqueness enforced per `(workspace_id, tenant_id, run_type)`.
|
||||
- [x] **Automation**: `ScanEntraAdminRolesJob` uses `OperationRunService::ensureRunWithIdentity()` for dedup. Fingerprint-based upsert handles concurrency for findings. Graph throttling handled by existing `GraphClientInterface` retry logic (429/503 backoff+jitter).
|
||||
- [x] **Data minimization**: Report payload contains only governance-relevant data (IDs, display names, principal types, scopes). No tokens, secrets, or excessive PII. Logs use stable error codes — no secrets/tokens in run failures.
|
||||
- [x] **Badge semantics (BADGE-001)**: `finding_type=entra_admin_roles` added to centralized `FindingTypeBadge` mapper with `BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification')`. Tests included.
|
||||
- [x] **Filament UI Action Surface Contract**: No new Resources/RelationManagers/Pages. Widget is a simple card with "Scan now" header action and "View latest report" link. Exemption: Widget is not a full Resource — Action Surface Contract does not apply to simple stat/card widgets. Documented in spec UI Action Matrix.
|
||||
- [x] **UX-001 (Layout & IA)**: No new Create/Edit/View pages. Widget follows existing card widget conventions. Report viewer reuses existing stored reports page. Exemption documented in spec.
|
||||
- [x] **SCOPE-001 ownership**: StoredReports are tenant-owned (`workspace_id` + `tenant_id` NOT NULL). Findings are tenant-owned (existing). AlertRule is workspace-owned (existing, no change).
|
||||
|
||||
- Add entries for:
|
||||
- `GET /roleManagement/directory/roleDefinitions`
|
||||
- `GET /roleManagement/directory/roleAssignments?$expand=principal`
|
||||
- Follow existing contract registration patterns (endpoint key, method, URI, scopes).
|
||||
**Post-Phase-1 re-check**: All items pass. No violations found.
|
||||
|
||||
### 1.4 Add RBAC capabilities
|
||||
## Project Structure
|
||||
|
||||
- Add to `App\Support\Auth\Capabilities`:
|
||||
- `ENTRA_ROLES_VIEW = 'entra_roles.view'`
|
||||
- `ENTRA_ROLES_MANAGE = 'entra_roles.manage'`
|
||||
- Update role-to-capability mapping:
|
||||
- Readonly/Operator → `ENTRA_ROLES_VIEW`
|
||||
- Manager/Owner → `ENTRA_ROLES_VIEW` + `ENTRA_ROLES_MANAGE`
|
||||
### Documentation (this feature)
|
||||
|
||||
### 1.5 Add OperationRunType case
|
||||
```text
|
||||
specs/105-entra-admin-roles-evidence-findings/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0: research decisions (R1-R10)
|
||||
├── data-model.md # Phase 1: entity/table design
|
||||
├── quickstart.md # Phase 1: implementation guide
|
||||
├── contracts/
|
||||
│ └── internal-services.md # Phase 1: service contracts
|
||||
├── checklists/
|
||||
│ └── requirements.md # Quality checklist (created by /speckit.tasks)
|
||||
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||
```
|
||||
|
||||
- Add `EntraAdminRolesScan = 'entra.admin_roles.scan'` to `App\Support\OperationRunType` enum.
|
||||
### Source Code (repository root)
|
||||
|
||||
---
|
||||
```text
|
||||
app/
|
||||
├── Models/
|
||||
│ ├── StoredReport.php # MODIFIED: +REPORT_TYPE_ENTRA_ADMIN_ROLES, +fingerprint fillables
|
||||
│ ├── Finding.php # MODIFIED: +FINDING_TYPE_ENTRA_ADMIN_ROLES constant
|
||||
│ └── AlertRule.php # MODIFIED: +EVENT_ENTRA_ADMIN_ROLES_HIGH constant
|
||||
├── Services/
|
||||
│ ├── EntraAdminRoles/
|
||||
│ │ ├── HighPrivilegeRoleCatalog.php # NEW: role classification (template_id → severity)
|
||||
│ │ ├── EntraAdminRolesReportService.php # NEW: Graph fetch + report creation + dedup
|
||||
│ │ ├── EntraAdminRolesReportResult.php # NEW: value object
|
||||
│ │ ├── EntraAdminRolesFindingGenerator.php # NEW: findings lifecycle (create/resolve/reopen)
|
||||
│ │ └── EntraAdminRolesFindingResult.php # NEW: value object
|
||||
│ └── Intune/
|
||||
│ └── TenantPermissionService.php # MODIFIED: merge entra_permissions into getRequiredPermissions()
|
||||
├── Jobs/
|
||||
│ ├── ScanEntraAdminRolesJob.php # NEW: queued orchestrator job
|
||||
│ └── Alerts/
|
||||
│ └── EvaluateAlertsJob.php # MODIFIED: +entraAdminRolesHighEvents() method
|
||||
├── Support/
|
||||
│ ├── OperationRunType.php # MODIFIED: +EntraAdminRolesScan case
|
||||
│ ├── Auth/
|
||||
│ │ ├── Capabilities.php # MODIFIED: +ENTRA_ROLES_VIEW, +ENTRA_ROLES_MANAGE
|
||||
│ │ └── RoleCapabilityMap.php # MODIFIED: map new capabilities to roles
|
||||
│ └── Badges/Domains/
|
||||
│ └── FindingTypeBadge.php # MODIFIED: +entra_admin_roles badge mapping
|
||||
├── Filament/
|
||||
│ ├── Resources/
|
||||
│ │ └── AlertRuleResource.php # MODIFIED: +EVENT_ENTRA_ADMIN_ROLES_HIGH in dropdown
|
||||
│ └── Widgets/Tenant/
|
||||
│ └── AdminRolesSummaryWidget.php # NEW: tenant dashboard card widget
|
||||
└── Providers/
|
||||
└── (no changes)
|
||||
|
||||
## Phase 2 — Core Services
|
||||
config/
|
||||
├── entra_permissions.php # NEW: Entra permission registry
|
||||
└── graph_contracts.php # MODIFIED: +entraRoleDefinitions, +entraRoleAssignments
|
||||
|
||||
### 2.1 Implement `HighPrivilegeRoleCatalog`
|
||||
database/
|
||||
├── migrations/
|
||||
│ └── XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns
|
||||
└── factories/
|
||||
└── FindingFactory.php # MODIFIED: +entraAdminRoles() state
|
||||
|
||||
- Create `App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog`.
|
||||
- Hardcoded mapping of template_id → (display_name, severity).
|
||||
- Public methods:
|
||||
- `isHighPrivilege(string $templateId, ?string $displayName): bool`
|
||||
- `severityFor(string $templateId, ?string $displayName): ?string`
|
||||
- `allTemplateIds(): array`
|
||||
- `globalAdminTemplateId(): string`
|
||||
- Fallback: if `template_id` not in catalog, check `display_name`.
|
||||
- Full unit test coverage.
|
||||
resources/views/filament/widgets/tenant/
|
||||
└── admin-roles-summary.blade.php # NEW: card template
|
||||
|
||||
### 2.2 Implement `EntraAdminRolesReportService`
|
||||
routes/
|
||||
└── console.php # MODIFIED: schedule daily admin roles scan
|
||||
|
||||
- Create `App\Services\EntraAdminRoles\EntraAdminRolesReportService`.
|
||||
- Responsibilities:
|
||||
1. Fetch `roleDefinitions` via Graph (using registered contract).
|
||||
2. Fetch `roleAssignments?$expand=principal` via Graph.
|
||||
3. Build payload per spec (role_definitions, role_assignments, totals, high_privilege).
|
||||
4. Compute fingerprint: SHA-256 of sorted `(role_template_or_id, principal_id, scope_id)` tuples.
|
||||
5. Determine `previous_fingerprint` from latest existing report for this tenant.
|
||||
6. Dedupe: if current fingerprint == latest report's fingerprint, skip creation.
|
||||
7. Create `StoredReport` with `report_type=entra.admin_roles`.
|
||||
- Add `REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles'` constant to `StoredReport` model.
|
||||
- Tests: report creation, dedupe, fingerprint computation, previous_fingerprint chaining.
|
||||
tests/Feature/EntraAdminRoles/
|
||||
├── HighPrivilegeRoleCatalogTest.php # NEW
|
||||
├── EntraAdminRolesReportServiceTest.php # NEW
|
||||
├── EntraAdminRolesFindingGeneratorTest.php # NEW
|
||||
├── ScanEntraAdminRolesJobTest.php # NEW
|
||||
├── AdminRolesAlertIntegrationTest.php # NEW
|
||||
├── AdminRolesSummaryWidgetTest.php # NEW
|
||||
├── EntraPermissionsRegistryTest.php # NEW
|
||||
└── StoredReportFingerprintTest.php # NEW
|
||||
```
|
||||
|
||||
### 2.3 Implement `EntraAdminRolesFindingGenerator`
|
||||
**Structure Decision**: Standard Laravel monolith structure. New services go under `app/Services/EntraAdminRoles/`. Tests mirror the service structure under `tests/Feature/EntraAdminRoles/`. Widget follows existing tenant dashboard card pattern.
|
||||
|
||||
- Create `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`.
|
||||
- Responsibilities:
|
||||
1. Accept report payload (or structured DTO).
|
||||
2. For each role assignment where role is high-privilege (per `HighPrivilegeRoleCatalog`):
|
||||
- Compute fingerprint: `entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}`
|
||||
- Upsert finding: `finding_type=entra_admin_roles`, `source=entra.admin_roles`
|
||||
- Set severity per catalog (critical for GA, high otherwise)
|
||||
- Populate `evidence_jsonb` with role, principal, scope details
|
||||
- Update `times_seen`, `last_seen_at` on existing findings
|
||||
3. Aggregate check: count GA principals > threshold (5) → aggregate finding with distinct fingerprint.
|
||||
4. Auto-resolve: query open findings for this tenant+source not in current scan's fingerprint set → resolve.
|
||||
5. Re-open: if a resolved finding's fingerprint matches current data → set status=new, clear resolved fields.
|
||||
6. Collect alert events for new/re-opened findings.
|
||||
- Add `FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles'` constant to `Finding` model.
|
||||
- Tests: creation, idempotency, auto-resolve, re-open, aggregate finding, alert events.
|
||||
## Complexity Tracking
|
||||
|
||||
---
|
||||
> No constitution violations. No complexity justifications needed.
|
||||
|
||||
## Phase 3 — Job & Scheduling
|
||||
## Implementation Phases
|
||||
|
||||
### 3.1 Implement `ScanEntraAdminRolesJob`
|
||||
### Phase A — Foundation (Migration + Constants + Config)
|
||||
|
||||
- Create `App\Jobs\EntraAdminRoles\ScanEntraAdminRolesJob` (implements `ShouldQueue`).
|
||||
- Lifecycle:
|
||||
1. Check tenant has provider connection; skip if not.
|
||||
2. Create OperationRun (`entra.admin_roles.scan`, status=running).
|
||||
3. Call `EntraAdminRolesReportService::generate()`.
|
||||
4. Call `EntraAdminRolesFindingGenerator::generate()`.
|
||||
5. Dispatch alert events via `AlertDispatchService`.
|
||||
6. Mark OperationRun completed (success/failure with error details).
|
||||
- Active-run uniqueness: check for existing running OperationRun before starting.
|
||||
- Tests: job dispatching, OperationRun lifecycle, skip on no connection, failure handling.
|
||||
**Goal**: Establish the data layer, config, and constants that all other phases depend on.
|
||||
|
||||
### 3.2 Add to workspace dispatcher
|
||||
**Deliverables**:
|
||||
1. Migration: `add_fingerprint_to_stored_reports` — add `fingerprint` (string(64), nullable), `previous_fingerprint` (string(64), nullable), unique index on `[tenant_id, report_type, fingerprint]`, index on `[tenant_id, report_type, created_at DESC]`
|
||||
2. Config: `config/entra_permissions.php` — registry with `RoleManagement.Read.Directory` (type: application, features: ['entra-admin-roles'])
|
||||
3. Config: Add `entraRoleDefinitions` and `entraRoleAssignments` entries to `config/graph_contracts.php`
|
||||
4. Model: `StoredReport` — add `REPORT_TYPE_ENTRA_ADMIN_ROLES` constant, add `fingerprint` + `previous_fingerprint` to fillable
|
||||
5. Model: `Finding` — add `FINDING_TYPE_ENTRA_ADMIN_ROLES` constant
|
||||
6. Model: `AlertRule` — add `EVENT_ENTRA_ADMIN_ROLES_HIGH` constant
|
||||
7. Enum: `OperationRunType` — add `EntraAdminRolesScan` case
|
||||
8. Constants: `Capabilities` — add `ENTRA_ROLES_VIEW`, `ENTRA_ROLES_MANAGE`
|
||||
9. Constants: `RoleCapabilityMap` — map new capabilities (Readonly/Operator → VIEW; Manager/Owner → MANAGE)
|
||||
10. Badge: Add `entra_admin_roles` mapping to `FindingTypeBadge`
|
||||
11. Factory: Add `entraAdminRoles()` state to `FindingFactory`
|
||||
12. Tests: StoredReport fingerprint migration (column exists), badge rendering, capabilities registry
|
||||
|
||||
- Register `ScanEntraAdminRolesJob` in the daily workspace dispatch schedule.
|
||||
- Same pattern as existing scheduled scans: iterate tenants with active connections, dispatch per tenant.
|
||||
**Dependencies**: None (foundation layer).
|
||||
|
||||
---
|
||||
### Phase B — High-Privilege Catalog + Report Service
|
||||
|
||||
## Phase 4 — Alerts Integration
|
||||
**Goal**: Implement role classification and the Graph-backed report generation service.
|
||||
|
||||
### 4.1 Add alert event type
|
||||
**Deliverables**:
|
||||
1. Service: `HighPrivilegeRoleCatalog` — static catalog with `classify()`, `isHighPrivilege()`, `isGlobalAdministrator()`, `allTemplateIds()`
|
||||
2. Value object: `EntraAdminRolesReportResult` — `(created, storedReportId, fingerprint, payload)`
|
||||
3. Service: `EntraAdminRolesReportService` — fetches Graph data, builds payload (FR-005), computes fingerprint, creates/deduplicates StoredReport, sets `previous_fingerprint`
|
||||
4. Tests: Catalog classification (all 6 roles, display name fallback, unknown roles, null display name), report service (new report creation, dedup on identical fingerprint, previous_fingerprint chain, all-or-nothing on partial Graph failure, payload schema validation)
|
||||
|
||||
- Add constant `EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high'` to `AlertRule` model.
|
||||
- Add to event type options array (used in alert rule form dropdown).
|
||||
**Dependencies**: Phase A (constants, config, migration).
|
||||
|
||||
### 4.2 Extend `EvaluateAlertsJob`
|
||||
### Phase C — Finding Generator
|
||||
|
||||
- Add a new producer section in `EvaluateAlertsJob` that:
|
||||
- Queries findings where `source=entra.admin_roles`, `severity>=high`, `status` in (`new`).
|
||||
- Produces events with `event_type=entra.admin_roles.high`.
|
||||
- Uses finding fingerprint as `fingerprint_key` for cooldown/dedupe.
|
||||
- Tests: alert rule matching, delivery creation, cooldown.
|
||||
**Goal**: Implement the finding lifecycle — create, upsert, auto-resolve, re-open, aggregate threshold.
|
||||
|
||||
---
|
||||
**Deliverables**:
|
||||
1. Value object: `EntraAdminRolesFindingResult` — `(created, resolved, reopened, unchanged, alertEventsProduced)`
|
||||
2. Service: `EntraAdminRolesFindingGenerator` — per (principal, role) findings with fingerprint-based idempotency, auto-resolve stale findings, re-open resolved findings, aggregate "Too many Global Admins" finding, alert event production
|
||||
3. Tests: Finding creation (severity mapping), idempotent upsert (`times_seen` / `last_seen_at` update), auto-resolve on removed assignment, re-open on re-assigned role, aggregate finding (threshold exceeded/within), evidence schema, alert event production for new/re-opened findings, no events for unchanged/resolved
|
||||
|
||||
## Phase 5 — Badge Catalog
|
||||
**Dependencies**: Phase B (catalog, report service provides payload structure).
|
||||
|
||||
### 5.1 Add `entra_admin_roles` to `FindingTypeBadge`
|
||||
### Phase D — Scan Job + Scheduling + Permission Posture Integration
|
||||
|
||||
- Extend `App\Support\Badges\Domains\FindingTypeBadge` mapper with:
|
||||
- `Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-shield-exclamation')`
|
||||
- Test: badge renders correctly for new type.
|
||||
**Goal**: Wire everything together as a queued job with scheduling and integrate Entra permissions into the posture pipeline.
|
||||
|
||||
---
|
||||
**Deliverables**:
|
||||
1. Job: `ScanEntraAdminRolesJob` — creates OperationRun via `ensureRunWithIdentity()`, calls report service, calls finding generator, records outcome. Skips gracefully if no active provider connection.
|
||||
2. Schedule: Register daily scan command in `routes/console.php` (iterate workspaces → tenants with active connections → dispatch per-tenant)
|
||||
3. Modify: `TenantPermissionService::getRequiredPermissions()` — merge `config('entra_permissions.permissions', [])` alongside existing Intune permissions
|
||||
4. Tests: Job dispatch + OperationRun lifecycle, skip-if-no-connection, error handling (Graph failure → run failure), permissions registry merge (Intune + Entra), posture score reflects Entra permission gaps
|
||||
|
||||
## Phase 6 — UI
|
||||
**Dependencies**: Phase C (finding generator).
|
||||
|
||||
### 6.1 Tenant card widget "Admin Roles"
|
||||
### Phase E — Alerts Integration
|
||||
|
||||
- Create a Livewire/Filament widget for the tenant dashboard.
|
||||
- Displays: last scan timestamp, high-privilege assignment count.
|
||||
- CTA: "Scan now" (visible only if user has `ENTRA_ROLES_MANAGE`).
|
||||
- Link: "View latest report" (stored reports viewer filtered by `entra.admin_roles`).
|
||||
- Empty state: "No scan performed" + gated "Scan now" CTA.
|
||||
**Goal**: Connect admin roles findings to the existing alert pipeline.
|
||||
|
||||
### 6.2 Report viewer enhancements
|
||||
**Deliverables**:
|
||||
1. Modify: `EvaluateAlertsJob` — add `entraAdminRolesHighEvents()` method (query `finding_type=entra_admin_roles`, `status IN (new)`, `severity IN (high, critical)`, `updated_at > $windowStart`). Call in `handle()` alongside existing event methods.
|
||||
2. UI: Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` to event type dropdown in `AlertRuleResource`
|
||||
3. Tests: Alert event production, severity filtering, cooldown/dedupe, alert rule matching for new event type
|
||||
|
||||
- Extend existing stored reports viewer to handle `report_type=entra.admin_roles`:
|
||||
- Summary section showing totals.
|
||||
- Table of high-privilege role assignments (principal display name, type, role, scope).
|
||||
- Filter by `report_type` on report listing.
|
||||
**Dependencies**: Phase D (job produces findings that generate alert events).
|
||||
|
||||
---
|
||||
### Phase F — Tenant Dashboard Widget
|
||||
|
||||
## Phase 7 — Tests
|
||||
**Goal**: Provide a tenant dashboard card for admin roles posture at-a-glance.
|
||||
|
||||
### 7.1 Unit tests
|
||||
**Deliverables**:
|
||||
1. Widget: `AdminRolesSummaryWidget` — extends `Widget`, resolves tenant via `Filament::getTenant()`, queries latest `stored_report` (type=entra.admin_roles), displays last scan timestamp + high-privilege count
|
||||
2. View: `admin-roles-summary.blade.php` — card template with summary stats, empty state ("No scan performed"), "Scan now" CTA (gated by `ENTRA_ROLES_MANAGE`), "View latest report" link (gated by `ENTRA_ROLES_VIEW`)
|
||||
3. RBAC: `canView()` gated by `ENTRA_ROLES_VIEW`. "Scan now" action checks `ENTRA_ROLES_MANAGE` server-side.
|
||||
4. Tests: Widget renders with report data, empty state rendering, "Scan now" dispatches job (with RBAC), widget hidden without `ENTRA_ROLES_VIEW`
|
||||
|
||||
- `HighPrivilegeRoleCatalog`: classification by template_id, fallback to display_name, unknown role returns false.
|
||||
- `EntraAdminRolesReportService`: report creation, fingerprint computation, dedupe, previous_fingerprint.
|
||||
- `EntraAdminRolesFindingGenerator`: finding creation, severity assignment, idempotent upsert, auto-resolve, re-open, aggregate finding.
|
||||
- Registry merge: combined Intune + Entra permissions.
|
||||
**Dependencies**: Phases A-D complete (job, reports, constants).
|
||||
|
||||
### 7.2 Feature tests
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
- `ScanEntraAdminRolesJob`: full lifecycle (report + findings + alerts), skip unconnected, failure handling.
|
||||
- Alerts: event type matching, delivery creation, cooldown.
|
||||
- RBAC: `ENTRA_ROLES_VIEW` can view; `ENTRA_ROLES_MANAGE` can scan; missing capability → 403; non-member → 404.
|
||||
|
||||
### 7.3 Posture integration smoke test
|
||||
|
||||
- With Entra permissions in merged registry, posture generator includes them in score calculation.
|
||||
|
||||
### 7.4 Run full suite
|
||||
|
||||
- `vendor/bin/sail artisan test --compact` — all tests green.
|
||||
- `vendor/bin/sail bin pint --dirty` — code style clean.
|
||||
|
||||
---
|
||||
|
||||
## Filament v5 Compliance Notes
|
||||
|
||||
1. **Livewire v4.0+**: All widget components are Livewire v4 compatible.
|
||||
2. **Provider registration**: No new panel providers — existing admin panel in `bootstrap/providers.php`.
|
||||
3. **Global search**: No new globally searchable resources.
|
||||
4. **Destructive actions**: "Scan now" is non-destructive (read-only Graph call); no `requiresConfirmation()` needed.
|
||||
5. **Asset strategy**: No new heavy assets. Widget uses standard Filament components.
|
||||
6. **Testing plan**: Widget tested as Livewire component; finding generator + report service unit tested; job feature tested; RBAC positive/negative tests included.
|
||||
1. **Livewire v4.0+ compliance**: Yes — `AdminRolesSummaryWidget` extends Filament's `Widget` (Livewire v4 component). No Livewire v3 references.
|
||||
2. **Provider registration**: No new providers. Existing `AdminPanelProvider` in `bootstrap/providers.php` unchanged.
|
||||
3. **Global search**: No new globally searchable resources. Existing Finding resource global search behavior unchanged.
|
||||
4. **Destructive actions**: None introduced. "Scan now" is non-destructive (read-only Graph operation). No confirmation required.
|
||||
5. **Asset strategy**: No new frontend assets. Badge mapping is PHP-only. Widget uses a simple Blade template. No `filament:assets` changes needed.
|
||||
6. **Testing plan**: Pest feature tests for: HighPrivilegeRoleCatalog (classification), EntraAdminRolesReportService (Graph fetch + dedup), EntraAdminRolesFindingGenerator (create/resolve/reopen/aggregate/idempotency), ScanEntraAdminRolesJob (orchestration + OperationRun), AdminRolesAlertIntegration (event production + matching), AdminRolesSummaryWidget (Livewire component mount + rendering + RBAC), EntraPermissionsRegistry (merge correctness), StoredReportFingerprint (migration + dedup).
|
||||
|
||||
90
specs/105-entra-admin-roles-evidence-findings/quickstart.md
Normal file
90
specs/105-entra-admin-roles-evidence-findings/quickstart.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Quickstart: Entra Admin Roles Evidence + Findings (Spec 105)
|
||||
|
||||
**Branch**: `105-entra-admin-roles-evidence-findings`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel Sail running (`vendor/bin/sail up -d`)
|
||||
- Database migrated (`vendor/bin/sail artisan migrate`)
|
||||
- At least one tenant with a provider connection configured
|
||||
- Spec 104 (Provider Permission Posture) deployed (`stored_reports` table exists)
|
||||
|
||||
## New Files Created (Implementation Order)
|
||||
|
||||
### Phase A — Foundation (Config + Migration + Constants)
|
||||
|
||||
```
|
||||
config/entra_permissions.php # NEW: Entra permission registry
|
||||
database/migrations/XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns
|
||||
```
|
||||
|
||||
### Phase B — Catalog + Report Service
|
||||
|
||||
```
|
||||
app/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.php # NEW: role classification
|
||||
app/Services/EntraAdminRoles/EntraAdminRolesReportResult.php # NEW: value object
|
||||
app/Services/EntraAdminRoles/EntraAdminRolesReportService.php # NEW: Graph fetch + report creation
|
||||
```
|
||||
|
||||
### Phase C — Finding Generator
|
||||
|
||||
```
|
||||
app/Services/EntraAdminRoles/EntraAdminRolesFindingResult.php # NEW: value object
|
||||
app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php # NEW: findings lifecycle
|
||||
```
|
||||
|
||||
### Phase D — Job + Scheduling
|
||||
|
||||
```
|
||||
app/Jobs/ScanEntraAdminRolesJob.php # NEW: orchestrator job
|
||||
```
|
||||
|
||||
### Phase E — UI Widget
|
||||
|
||||
```
|
||||
app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php # NEW: dashboard card
|
||||
resources/views/filament/widgets/tenant/admin-roles-summary.blade.php # NEW: card template
|
||||
```
|
||||
|
||||
## Modified Files Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `app/Models/StoredReport.php` | Add `REPORT_TYPE_ENTRA_ADMIN_ROLES` constant, add `fingerprint`+`previous_fingerprint` to fillable |
|
||||
| `app/Models/Finding.php` | Add `FINDING_TYPE_ENTRA_ADMIN_ROLES` constant |
|
||||
| `app/Models/AlertRule.php` | Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` constant |
|
||||
| `app/Support/OperationRunType.php` | Add `EntraAdminRolesScan` case |
|
||||
| `app/Support/Auth/Capabilities.php` | Add `ENTRA_ROLES_VIEW`, `ENTRA_ROLES_MANAGE` constants |
|
||||
| `app/Support/Badges/Domains/FindingTypeBadge.php` | Add `entra_admin_roles` badge mapping |
|
||||
| `app/Services/Intune/TenantPermissionService.php` | Merge `entra_permissions.php` into `getRequiredPermissions()` |
|
||||
| `app/Jobs/Alerts/EvaluateAlertsJob.php` | Add `entraAdminRolesHighEvents()` method + call in `handle()` |
|
||||
| `config/graph_contracts.php` | Add `entraRoleDefinitions` + `entraRoleAssignments` type entries |
|
||||
| `app/Support/Auth/RoleCapabilityMap.php` (or equivalent) | Map new capabilities to roles |
|
||||
| `app/Filament/Resources/AlertRuleResource.php` | Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` to event type dropdown |
|
||||
| `routes/console.php` | Register daily scan schedule |
|
||||
| `database/factories/FindingFactory.php` | Add `entraAdminRoles()` state |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All Spec 105 tests
|
||||
vendor/bin/sail artisan test --compact --filter=EntraAdminRoles
|
||||
|
||||
# Specific test files
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/HighPrivilegeRoleCatalogTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Fingerprint on stored_reports**: Migration adds nullable columns — existing permission_posture reports unaffected
|
||||
2. **HighPrivilegeRoleCatalog**: Static PHP catalog using Microsoft template_ids (GUIDs), extensible later via workspace settings
|
||||
3. **Finding per (principal, role)**: One finding per role assignment, not per principal — enables precise auto-resolve
|
||||
4. **Registry merge**: `TenantPermissionService::getRequiredPermissions()` now merges both `intune_permissions.php` and `entra_permissions.php`
|
||||
5. **Alert integration**: Same pattern as existing alert event producers — new method in `EvaluateAlertsJob`
|
||||
6. **Widget not Resource**: Admin Roles card is a widget on the tenant dashboard, not a full CRUD resource
|
||||
7. **RBAC boundary**: `ENTRA_ROLES_VIEW` gates card+report only; findings use existing `FINDINGS_VIEW`
|
||||
116
specs/105-entra-admin-roles-evidence-findings/research.md
Normal file
116
specs/105-entra-admin-roles-evidence-findings/research.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Research: Entra Admin Roles Evidence + Findings (Spec 105)
|
||||
|
||||
**Date**: 2026-02-21 | **Branch**: `105-entra-admin-roles-evidence-findings`
|
||||
|
||||
## R1 — StoredReport Fingerprint + Deduplication
|
||||
|
||||
**Decision**: Add `fingerprint` and `previous_fingerprint` columns to `stored_reports` via a new migration.
|
||||
|
||||
**Rationale**: The spec requires content-based deduplication — if the role assignment data is identical between scans, no new report should be created. The existing `stored_reports` table (Spec 104) has no fingerprint columns. Spec 104's permission posture reports don't use fingerprinting (they always create a new report per compare run). Spec 105 introduces a different pattern: only store a new report when data has changed.
|
||||
|
||||
**Migration**:
|
||||
- Add `fingerprint` (string(64), nullable) — SHA-256 of sorted role assignment tuples
|
||||
- Add `previous_fingerprint` (string(64), nullable) — references the prior report's fingerprint for drift chain
|
||||
- Add unique index on `[tenant_id, report_type, fingerprint]` to enforce deduplication at DB level
|
||||
- Nullable because existing permission_posture reports have no fingerprint
|
||||
|
||||
**Alternatives considered**:
|
||||
- Fingerprint as payload field (not a column): Rejected because DB-level uniqueness constraint prevents race conditions
|
||||
- Separate table for Entra reports: Rejected because it fragments the report model unnecessarily
|
||||
|
||||
## R2 — Graph API Pattern: roleManagement/directory
|
||||
|
||||
**Decision**: Register two new endpoints in `config/graph_contracts.php` under a new `entraRoleDefinitions` and `entraRoleAssignments` type section. Graph calls go through `GraphClientInterface`.
|
||||
|
||||
**Rationale**: The constitution requires all Graph calls to go through the contract registry. The unified RBAC API uses `GET /roleManagement/directory/roleDefinitions` and `GET /roleManagement/directory/roleAssignments?$expand=principal`. These are read-only, paginated endpoints returning standard Graph collections.
|
||||
|
||||
**Implementation**:
|
||||
- New graph_contracts entry: `entraRoleDefinitions` → `roleManagement/directory/roleDefinitions`
|
||||
- New graph_contracts entry: `entraRoleAssignments` → `roleManagement/directory/roleAssignments`, with `allowed_expand: ['principal']`
|
||||
- The service uses `GraphClientInterface::getCollection()` (or equivalent paginated reader) to fetch all results
|
||||
|
||||
**Pagination**: Both endpoints return paginated results (standard `@odata.nextLink`). The existing paginated Graph reader pattern handles this.
|
||||
|
||||
## R3 — HighPrivilegeRoleCatalog Classification Strategy
|
||||
|
||||
**Decision**: Pure PHP class with a static catalog mapping `template_id` → severity. Fallback to `display_name` matching when `template_id` is null (custom roles).
|
||||
|
||||
**Rationale**: Microsoft documents stable `template_id` values for built-in roles. These are GUIDs that don't change across tenants. Using `template_id` as primary key ensures cross-tenant consistency. Display name fallback handles edge cases where `template_id` is null.
|
||||
|
||||
**v1 Catalog** (from spec):
|
||||
|
||||
| Role | Template ID | Severity |
|
||||
|------|------------|----------|
|
||||
| Global Administrator | `62e90394-69f5-4237-9190-012177145e10` | critical |
|
||||
| Privileged Role Administrator | `e8611ab8-c189-46e8-94e1-60213ab1f814` | high |
|
||||
| Security Administrator | `194ae4cb-b126-40b2-bd5b-6091b380977d` | high |
|
||||
| Conditional Access Administrator | `b1be1c3e-b65d-4f19-8427-f6fa0d97feb9` | high |
|
||||
| Exchange Administrator | `29232cdf-9323-42fd-ade2-1d097af3e4de` | high |
|
||||
| Authentication Administrator | `c4e39bd9-1100-46d3-8c65-fb160da0071f` | high |
|
||||
|
||||
**Extensibility**: The catalog class is a standalone value object — future specs can make this configurable per workspace via settings. v1 is hardcoded.
|
||||
|
||||
## R4 — Finding Fingerprint Formula
|
||||
|
||||
**Decision**: `entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}` hashed to SHA-256 (64 chars).
|
||||
|
||||
**Rationale**: This matches the existing pattern from `PermissionPostureFindingGenerator` which uses `permission_posture:{tenant_id}:{permission_key}`. Including all four dimensions (tenant, role, principal, scope) ensures uniqueness even if the same principal holds the same role at different scopes.
|
||||
|
||||
**Aggregate finding** (Too many GAs): `entra_admin_role_ga_count:{tenant_id}` — single fingerprint per tenant for the threshold finding.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Including `assignment_id` from Graph: Rejected because assignment IDs can change when role assignments are recreated, causing false re-opens
|
||||
|
||||
## R5 — Entra Permissions Registry Merge Strategy
|
||||
|
||||
**Decision**: Modify `TenantPermissionService::getRequiredPermissions()` to merge `config/intune_permissions.php` and `config/entra_permissions.php`.
|
||||
|
||||
**Rationale**: The current implementation at L23 hardcodes `config('intune_permissions.permissions', [])`. The merge must:
|
||||
1. Load both registries
|
||||
2. Concatenate permission arrays
|
||||
3. Return the merged set
|
||||
|
||||
This is a minimal change (2-3 lines). The posture generator and compare logic are generic — they iterate the returned permissions array regardless of source.
|
||||
|
||||
**Non-breaking guarantee**: Existing Intune permissions appear unchanged in the merged array. Posture scores recompute to include Entra permissions proportionally.
|
||||
|
||||
## R6 — Alert Event Integration
|
||||
|
||||
**Decision**: Add `entraAdminRolesHighEvents()` method to `EvaluateAlertsJob`, following the exact same pattern as `highDriftEvents()` and `permissionMissingEvents()`.
|
||||
|
||||
**Rationale**: The existing alert evaluation job collects events from dedicated methods and dispatches them. Adding one more method that queries `finding_type=entra_admin_roles` with `status=new` and severity `>= high` within the time window follows the established pattern exactly.
|
||||
|
||||
**Event structure**: Same shape as existing events (`event_type`, `tenant_id`, `severity`, `fingerprint_key`, `title`, `body`, `metadata`). The `fingerprint_key` uses `finding:{finding_id}` for cooldown dedup.
|
||||
|
||||
## R7 — OperationRunType for Scan
|
||||
|
||||
**Decision**: Add `EntraAdminRolesScan = 'entra.admin_roles.scan'` to the `OperationRunType` enum.
|
||||
|
||||
**Rationale**: The existing enum has cases like `InventorySync`, `PolicySync`, etc. The dot-notation value (`entra.admin_roles.scan`) follows the pattern of `policy.sync`, `policy.sync_one`, etc. Active-run uniqueness is enforced per `(workspace_id, tenant_id, run_type)` via `OperationRunService::ensureRunWithIdentity()`.
|
||||
|
||||
## R8 — Tenant Dashboard Widget Pattern
|
||||
|
||||
**Decision**: New `AdminRolesSummaryWidget` extending `Filament\Widgets\Widget` with a custom Blade view, matching the existing `RecentOperationsSummary` widget pattern.
|
||||
|
||||
**Rationale**: Existing tenant dashboard widgets (`RecentOperationsSummary`, `TenantVerificationReport`) use the same pattern: extend `Widget`, resolve the tenant from Filament context, query data, return view data. The Admin Roles card follows this exact pattern:
|
||||
1. Resolve tenant via `Filament::getTenant()`
|
||||
2. Query latest `stored_report` where `report_type=entra.admin_roles`
|
||||
3. Extract summary stats (timestamp, high-privilege count)
|
||||
4. Return view data to Blade template
|
||||
5. "Scan now" action dispatches job (gated by `ENTRA_ROLES_MANAGE`)
|
||||
|
||||
**RBAC**: The widget is gated by `ENTRA_ROLES_VIEW` via `canView()` method or `static::canView()`. The "Scan now" button checks `ENTRA_ROLES_MANAGE` separately.
|
||||
|
||||
## R9 — No New Migration for Findings Table
|
||||
|
||||
**Decision**: No migration needed for the `findings` table.
|
||||
|
||||
**Rationale**: The existing `findings` table already has all required columns: `finding_type`, `source`, `scope_key`, `fingerprint` (unique per tenant), `subject_type`, `subject_external_id`, `severity`, `status`, `evidence_jsonb`, `resolved_at`, `resolved_reason`. Adding `finding_type=entra_admin_roles` is a new value in an existing string column — no schema change.
|
||||
|
||||
## R10 — Scan Scheduling Pattern
|
||||
|
||||
**Decision**: Daily scheduled dispatch following existing workspace dispatcher pattern.
|
||||
|
||||
**Rationale**: The spec mentions "daily via workspace dispatcher alongside existing scheduled scans." The existing pattern in `routes/console.php` schedules a command that iterates workspaces and dispatches per-tenant jobs. The admin roles scan follows this same dispatching loop.
|
||||
|
||||
**Implementation**: Either extend the existing dispatcher or add a `ScheduleEntraAdminRolesScanCommand` that iterates workspaces → tenants with active connections → dispatches `ScanEntraAdminRolesJob` per tenant. On-demand scan uses the same job, dispatched from the widget action.
|
||||
Loading…
Reference in New Issue
Block a user