feat: Spec 105 — Entra Admin Roles Evidence + Findings (#128)
## Summary Automated scanning of Entra ID directory roles to surface high-privilege role assignments as trackable findings with alerting support. ## What's included ### Core Services - **EntraAdminRolesReportService** — Fetches role definitions + assignments via Graph API, builds payload with fingerprint deduplication - **EntraAdminRolesFindingGenerator** — Creates/resolves/reopens findings based on high-privilege role catalog - **HighPrivilegeRoleCatalog** — Curated list of high-privilege Entra roles (Global Admin, Privileged Auth Admin, etc.) - **ScanEntraAdminRolesJob** — Queued job orchestrating scan → report → findings → alerts pipeline ### UI - **AdminRolesSummaryWidget** — Tenant dashboard card showing last scan time, high-privilege assignment count, scan trigger button - RBAC-gated: `ENTRA_ROLES_VIEW` for viewing, `ENTRA_ROLES_MANAGE` for scan trigger ### Infrastructure - Graph contracts for `entraRoleDefinitions` + `entraRoleAssignments` - `config/entra_permissions.php` — Entra permission registry - `StoredReport.fingerprint` migration (deduplication support) - `OperationCatalog` label + duration for `entra.admin_roles.scan` - Artisan command `entra:scan-admin-roles` for CLI/scheduled use ### Global UX improvement - **SummaryCountsNormalizer**: Zero values filtered, snake_case keys humanized (e.g. `report_deduped: 1` → `Report deduped: 1`). Affects all operation notifications. ## Test Coverage - **12 test files**, **79+ tests**, **307+ assertions** - Report service, finding generator, job orchestration, widget rendering, alert integration, RBAC enforcement, badge mapping ## Spec artifacts - `specs/105-entra-admin-roles-evidence-findings/tasks.md` — Full task breakdown (38 tasks, all complete) - `specs/105-entra-admin-roles-evidence-findings/checklists/requirements.md` — All items checked ## Files changed 46 files changed, 3641 insertions(+), 15 deletions(-) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #128
This commit is contained in:
parent
ef380b67d1
commit
6a15fe978a
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 -->
|
||||
|
||||
@ -380,6 +380,7 @@ public static function eventTypeOptions(): array
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||
@ -33,6 +34,7 @@ protected function getHeaderWidgets(): array
|
||||
TenantArchivedBanner::class,
|
||||
RecentOperationsSummary::class,
|
||||
TenantVerificationReport::class,
|
||||
AdminRolesSummaryWidget::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
134
app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php
Normal file
134
app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Jobs\ScanEntraAdminRolesJob;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class AdminRolesSummaryWidget extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.admin-roles-summary';
|
||||
|
||||
public ?Tenant $record = null;
|
||||
|
||||
private function resolveTenant(): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return $this->record instanceof Tenant ? $this->record : null;
|
||||
}
|
||||
|
||||
public function scanNow(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::ENTRA_ROLES_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Entra admin roles scan queued')
|
||||
->body('The scan will run in the background. Results appear once complete.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $this->emptyState();
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||
$canView = $isTenantMember && $user->can(Capabilities::ENTRA_ROLES_VIEW, $tenant);
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::ENTRA_ROLES_MANAGE, $tenant);
|
||||
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if (! $report instanceof StoredReport) {
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'reportSummary' => null,
|
||||
'lastScanAt' => null,
|
||||
'highPrivilegeCount' => 0,
|
||||
'canManage' => $canManage,
|
||||
'canView' => $canView,
|
||||
'viewReportUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = is_array($report->payload) ? $report->payload : [];
|
||||
$totals = is_array($payload['totals'] ?? null) ? $payload['totals'] : [];
|
||||
$highPrivilegeCount = (int) ($totals['high_privilege_assignments'] ?? 0);
|
||||
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'reportSummary' => $totals,
|
||||
'lastScanAt' => $report->created_at?->diffForHumans() ?? '—',
|
||||
'highPrivilegeCount' => $highPrivilegeCount,
|
||||
'canManage' => $canManage,
|
||||
'canView' => $canView,
|
||||
'viewReportUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function emptyState(): array
|
||||
{
|
||||
return [
|
||||
'tenant' => null,
|
||||
'reportSummary' => null,
|
||||
'lastScanAt' => null,
|
||||
'highPrivilegeCount' => 0,
|
||||
'canManage' => false,
|
||||
'canView' => false,
|
||||
'viewReportUrl' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||
];
|
||||
|
||||
$createdDeliveries = 0;
|
||||
@ -293,4 +294,46 @@ private function permissionMissingEvents(int $workspaceId, CarbonImmutable $wind
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL])
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('updated_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
$events[] = [
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'tenant_id' => (int) $finding->tenant_id,
|
||||
'severity' => (string) $finding->severity,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'High-privilege Entra admin role detected',
|
||||
'body' => sprintf(
|
||||
'Role "%s" assigned to %s (severity: %s).',
|
||||
(string) ($evidence['role_display_name'] ?? 'unknown'),
|
||||
(string) ($evidence['principal_display_name'] ?? $finding->subject_external_id ?? 'unknown'),
|
||||
(string) $finding->severity,
|
||||
),
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'role_display_name' => (string) ($evidence['role_display_name'] ?? ''),
|
||||
'principal_display_name' => (string) ($evidence['principal_display_name'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
}
|
||||
|
||||
115
app/Jobs/ScanEntraAdminRolesJob.php
Normal file
115
app/Jobs/ScanEntraAdminRolesJob.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class ScanEntraAdminRolesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly ?int $initiatorUserId = null,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
EntraAdminRolesReportService $reportService,
|
||||
EntraAdminRolesFindingGenerator $findingGenerator,
|
||||
OperationRunService $operationRuns,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FR-018: Skip tenants without active provider connection
|
||||
$hasConnection = ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', 'connected')
|
||||
->exists();
|
||||
|
||||
if (! $hasConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $this->initiatorUserId !== null
|
||||
? User::query()->find($this->initiatorUserId)
|
||||
: null;
|
||||
|
||||
$operationRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'entra.admin_roles.scan',
|
||||
identityInputs: [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'trigger' => 'scan',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'initiator_user_id' => $this->initiatorUserId,
|
||||
],
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
);
|
||||
|
||||
try {
|
||||
$reportResult = $reportService->generate($tenant, $operationRun);
|
||||
|
||||
$findingResult = $findingGenerator->generate($tenant, $reportResult->payload, $operationRun);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'report_created' => $reportResult->created ? 1 : 0,
|
||||
'report_deduped' => $reportResult->created ? 0 : 1,
|
||||
'findings_created' => $findingResult->created,
|
||||
'findings_resolved' => $findingResult->resolved,
|
||||
'findings_reopened' => $findingResult->reopened,
|
||||
'findings_unchanged' => $findingResult->unchanged,
|
||||
'alert_events_produced' => $findingResult->alertEventsProduced,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'entra.admin_roles.scan.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : 'Entra admin roles scan failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,8 @@ class AlertRule extends Model
|
||||
|
||||
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
|
||||
|
||||
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
||||
|
||||
public const string TENANT_SCOPE_ALL = 'all';
|
||||
|
||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||
|
||||
@ -18,6 +18,8 @@ class Finding extends Model
|
||||
|
||||
public const string FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
|
||||
public const string FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
|
||||
@ -16,11 +16,15 @@ class StoredReport extends Model
|
||||
|
||||
public const string REPORT_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
|
||||
public const string REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'tenant_id',
|
||||
'report_type',
|
||||
'payload',
|
||||
'fingerprint',
|
||||
'previous_fingerprint',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -37,6 +37,9 @@ class RoleCapabilityMap
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -59,6 +62,9 @@ class RoleCapabilityMap
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -76,6 +82,8 @@ class RoleCapabilityMap
|
||||
Capabilities::PROVIDER_RUN,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
@ -87,6 +95,8 @@ class RoleCapabilityMap
|
||||
Capabilities::PROVIDER_VIEW,
|
||||
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
330
app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
Normal file
330
app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
Normal file
@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EntraAdminRoles;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class EntraAdminRolesFindingGenerator
|
||||
{
|
||||
private const int GA_THRESHOLD = 5; // TODO: move to settings when configurable thresholds are implemented
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $alertEvents = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HighPrivilegeRoleCatalog $catalog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate/upsert/auto-resolve findings based on report payload data.
|
||||
*/
|
||||
public function generate(
|
||||
Tenant $tenant,
|
||||
array $reportPayload,
|
||||
?OperationRun $operationRun = null,
|
||||
): EntraAdminRolesFindingResult {
|
||||
$this->alertEvents = [];
|
||||
|
||||
$created = 0;
|
||||
$resolved = 0;
|
||||
$reopened = 0;
|
||||
$unchanged = 0;
|
||||
|
||||
$roleAssignments = is_array($reportPayload['role_assignments'] ?? null) ? $reportPayload['role_assignments'] : [];
|
||||
$roleDefinitions = is_array($reportPayload['role_definitions'] ?? null) ? $reportPayload['role_definitions'] : [];
|
||||
$measuredAt = (string) ($reportPayload['measured_at'] ?? CarbonImmutable::now('UTC')->toIso8601String());
|
||||
|
||||
$roleDefMap = $this->buildRoleDefMap($roleDefinitions);
|
||||
|
||||
$currentFingerprints = [];
|
||||
$gaCount = 0;
|
||||
$gaPrincipals = [];
|
||||
|
||||
foreach ($roleAssignments as $assignment) {
|
||||
$roleDefId = (string) ($assignment['roleDefinitionId'] ?? '');
|
||||
$roleDef = $roleDefMap[$roleDefId] ?? null;
|
||||
$templateId = (string) ($roleDef['templateId'] ?? $roleDefId);
|
||||
$displayName = $roleDef !== null ? (string) ($roleDef['displayName'] ?? '') : null;
|
||||
|
||||
$severity = $this->catalog->classify($templateId, $displayName);
|
||||
|
||||
if ($severity === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$principalId = (string) ($assignment['principalId'] ?? '');
|
||||
$scopeId = (string) ($assignment['directoryScopeId'] ?? '/');
|
||||
$principal = $assignment['principal'] ?? [];
|
||||
|
||||
$fingerprint = $this->individualFingerprint($tenant, $templateId, $principalId, $scopeId);
|
||||
$currentFingerprints[] = $fingerprint;
|
||||
|
||||
$evidence = $this->buildEvidence($assignment, $roleDef, $principal, $severity, $measuredAt);
|
||||
|
||||
$result = $this->upsertFinding($tenant, $fingerprint, $severity, $evidence, $principalId, $roleDefId, $operationRun);
|
||||
|
||||
match ($result) {
|
||||
'created' => $created++,
|
||||
'reopened' => $reopened++,
|
||||
default => $unchanged++,
|
||||
};
|
||||
|
||||
if (in_array($result, ['created', 'reopened'], true) && in_array($severity, [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL], true)) {
|
||||
$this->produceAlertEvent($tenant, $fingerprint, $evidence);
|
||||
}
|
||||
|
||||
if ($this->catalog->isGlobalAdministrator($templateId, $displayName)) {
|
||||
$gaCount++;
|
||||
$gaPrincipals[] = [
|
||||
'display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
|
||||
'type' => $this->resolvePrincipalType($principal),
|
||||
'id' => $principalId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate "Too many Global Admins" finding
|
||||
$resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $operationRun, $created, $reopened);
|
||||
|
||||
// Auto-resolve stale findings
|
||||
$resolved += $this->resolveStaleFindings($tenant, $currentFingerprints);
|
||||
|
||||
return new EntraAdminRolesFindingResult(
|
||||
created: $created,
|
||||
resolved: $resolved,
|
||||
reopened: $reopened,
|
||||
unchanged: $unchanged,
|
||||
alertEventsProduced: count($this->alertEvents),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAlertEvents(): array
|
||||
{
|
||||
return $this->alertEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function buildRoleDefMap(array $roleDefinitions): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($roleDefinitions as $def) {
|
||||
$id = (string) ($def['id'] ?? '');
|
||||
|
||||
if ($id !== '') {
|
||||
$map[$id] = $def;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function upsertFinding(
|
||||
Tenant $tenant,
|
||||
string $fingerprint,
|
||||
string $severity,
|
||||
array $evidence,
|
||||
string $principalId,
|
||||
string $roleDefId,
|
||||
?OperationRun $operationRun,
|
||||
): string {
|
||||
$existing = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $fingerprint)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
if ($existing->status === Finding::STATUS_RESOLVED) {
|
||||
$existing->reopen($evidence);
|
||||
|
||||
return 'reopened';
|
||||
}
|
||||
|
||||
// Update evidence on existing open finding
|
||||
$existing->update(['evidence_jsonb' => $evidence]);
|
||||
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
Finding::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'source' => 'entra.admin_roles',
|
||||
'scope_key' => hash('sha256', 'entra_admin_roles:'.$tenant->getKey()),
|
||||
'fingerprint' => $fingerprint,
|
||||
'subject_type' => 'role_assignment',
|
||||
'subject_external_id' => "{$principalId}:{$roleDefId}",
|
||||
'severity' => $severity,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'current_operation_run_id' => $operationRun?->getKey(),
|
||||
]);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle aggregate "Too many Global Admins" finding.
|
||||
*
|
||||
* @return int Number of resolved findings (0 or 1)
|
||||
*/
|
||||
private function handleGaAggregate(
|
||||
Tenant $tenant,
|
||||
int $gaCount,
|
||||
array $gaPrincipals,
|
||||
array &$currentFingerprints,
|
||||
?OperationRun $operationRun,
|
||||
int &$created,
|
||||
int &$reopened,
|
||||
): int {
|
||||
$gaFingerprint = $this->gaAggregateFingerprint($tenant);
|
||||
$currentFingerprints[] = $gaFingerprint;
|
||||
|
||||
$resolved = 0;
|
||||
|
||||
if ($gaCount > self::GA_THRESHOLD) {
|
||||
$evidence = [
|
||||
'count' => $gaCount,
|
||||
'threshold' => self::GA_THRESHOLD,
|
||||
'principals' => $gaPrincipals,
|
||||
];
|
||||
|
||||
$existing = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $gaFingerprint)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
if ($existing->status === Finding::STATUS_RESOLVED) {
|
||||
$existing->reopen($evidence);
|
||||
$reopened++;
|
||||
$this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
|
||||
} else {
|
||||
$existing->update(['evidence_jsonb' => $evidence]);
|
||||
}
|
||||
} else {
|
||||
Finding::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'source' => 'entra.admin_roles',
|
||||
'scope_key' => hash('sha256', 'entra_admin_roles_ga_count:'.$tenant->getKey()),
|
||||
'fingerprint' => $gaFingerprint,
|
||||
'subject_type' => 'role_assignment',
|
||||
'subject_external_id' => 'ga_aggregate',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'current_operation_run_id' => $operationRun?->getKey(),
|
||||
]);
|
||||
$created++;
|
||||
$this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
|
||||
}
|
||||
} else {
|
||||
// Auto-resolve aggregate if threshold met
|
||||
$existing = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $gaFingerprint)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
$existing->resolve('ga_count_within_threshold');
|
||||
$resolved++;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve open findings whose fingerprint is not in the current scan.
|
||||
*/
|
||||
private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints): int
|
||||
{
|
||||
$staleFindings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->whereNotIn('fingerprint', $currentFingerprints)
|
||||
->get();
|
||||
|
||||
$resolved = 0;
|
||||
|
||||
foreach ($staleFindings as $finding) {
|
||||
$finding->resolve('role_assignment_removed');
|
||||
$resolved++;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void
|
||||
{
|
||||
$this->alertEvents[] = [
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => $evidence['severity'] ?? Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'entra_admin_role:'.$fingerprint,
|
||||
'title' => 'High-privilege Entra admin role detected',
|
||||
'body' => sprintf(
|
||||
'Role "%s" assigned to %s on tenant %s.',
|
||||
$evidence['role_display_name'] ?? 'Unknown',
|
||||
$evidence['principal_display_name'] ?? 'Unknown',
|
||||
$tenant->name ?? (string) $tenant->getKey(),
|
||||
),
|
||||
'metadata' => $evidence,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEvidence(array $assignment, ?array $roleDef, array $principal, string $severity, string $measuredAt): array
|
||||
{
|
||||
return [
|
||||
'role_display_name' => (string) ($roleDef['displayName'] ?? 'Unknown'),
|
||||
'principal_display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
|
||||
'principal_type' => $this->resolvePrincipalType($principal),
|
||||
'principal_id' => (string) ($assignment['principalId'] ?? ''),
|
||||
'role_definition_id' => (string) ($assignment['roleDefinitionId'] ?? ''),
|
||||
'role_template_id' => (string) ($roleDef['templateId'] ?? ''),
|
||||
'directory_scope_id' => (string) ($assignment['directoryScopeId'] ?? '/'),
|
||||
'is_built_in' => (bool) ($roleDef['isBuiltIn'] ?? false),
|
||||
'severity' => $severity,
|
||||
'measured_at' => $measuredAt,
|
||||
];
|
||||
}
|
||||
|
||||
private function individualFingerprint(Tenant $tenant, string $templateId, string $principalId, string $scopeId): string
|
||||
{
|
||||
return substr(hash('sha256', "entra_admin_role:{$tenant->getKey()}:{$templateId}:{$principalId}:{$scopeId}"), 0, 64);
|
||||
}
|
||||
|
||||
private function gaAggregateFingerprint(Tenant $tenant): string
|
||||
{
|
||||
return substr(hash('sha256', "entra_admin_role_ga_count:{$tenant->getKey()}"), 0, 64);
|
||||
}
|
||||
|
||||
private function resolvePrincipalType(array $principal): string
|
||||
{
|
||||
$odataType = (string) ($principal['@odata.type'] ?? '');
|
||||
|
||||
return match (true) {
|
||||
str_contains($odataType, 'user') => 'user',
|
||||
str_contains($odataType, 'group') => 'group',
|
||||
str_contains($odataType, 'servicePrincipal') => 'servicePrincipal',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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,
|
||||
) {}
|
||||
}
|
||||
15
app/Services/EntraAdminRoles/EntraAdminRolesReportResult.php
Normal file
15
app/Services/EntraAdminRoles/EntraAdminRolesReportResult.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EntraAdminRoles;
|
||||
|
||||
final readonly class EntraAdminRolesReportResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $created,
|
||||
public ?int $storedReportId,
|
||||
public string $fingerprint,
|
||||
public array $payload,
|
||||
) {}
|
||||
}
|
||||
193
app/Services/EntraAdminRoles/EntraAdminRolesReportService.php
Normal file
193
app/Services/EntraAdminRoles/EntraAdminRolesReportService.php
Normal file
@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EntraAdminRoles;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class EntraAdminRolesReportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly HighPrivilegeRoleCatalog $catalog,
|
||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch Graph data and produce a stored report for the given tenant.
|
||||
*/
|
||||
public function generate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResult
|
||||
{
|
||||
$graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||
|
||||
$roleDefinitions = $this->fetchRoleDefinitions($graphOptions);
|
||||
$roleAssignments = $this->fetchRoleAssignments($graphOptions);
|
||||
|
||||
$payload = $this->buildPayload($roleDefinitions, $roleAssignments);
|
||||
$fingerprint = $this->computeFingerprint($roleDefinitions, $roleAssignments);
|
||||
|
||||
$latestReport = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($latestReport instanceof StoredReport && $latestReport->fingerprint === $fingerprint) {
|
||||
return new EntraAdminRolesReportResult(
|
||||
created: false,
|
||||
storedReportId: (int) $latestReport->getKey(),
|
||||
fingerprint: $fingerprint,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
$report = StoredReport::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => $payload,
|
||||
'fingerprint' => $fingerprint,
|
||||
'previous_fingerprint' => $latestReport?->fingerprint,
|
||||
]);
|
||||
|
||||
return new EntraAdminRolesReportResult(
|
||||
created: true,
|
||||
storedReportId: (int) $report->getKey(),
|
||||
fingerprint: $fingerprint,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchRoleDefinitions(array $graphOptions): array
|
||||
{
|
||||
$response = $this->graphClient->listPolicies('entraRoleDefinitions', $graphOptions);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Failed to fetch Entra role definitions from Graph API.');
|
||||
}
|
||||
|
||||
return $response->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchRoleAssignments(array $graphOptions): array
|
||||
{
|
||||
$response = $this->graphClient->listPolicies('entraRoleAssignments', $graphOptions);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Failed to fetch Entra role assignments from Graph API.');
|
||||
}
|
||||
|
||||
return $response->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(array $roleDefinitions, array $roleAssignments): array
|
||||
{
|
||||
$roleDefMap = [];
|
||||
|
||||
foreach ($roleDefinitions as $def) {
|
||||
$id = (string) ($def['id'] ?? '');
|
||||
|
||||
if ($id !== '') {
|
||||
$roleDefMap[$id] = $def;
|
||||
}
|
||||
}
|
||||
|
||||
$highPrivilegeAssignments = [];
|
||||
|
||||
foreach ($roleAssignments as $assignment) {
|
||||
$roleDefId = (string) ($assignment['roleDefinitionId'] ?? '');
|
||||
$roleDef = $roleDefMap[$roleDefId] ?? null;
|
||||
$templateId = (string) ($roleDef['templateId'] ?? $roleDefId);
|
||||
$displayName = $roleDef !== null ? (string) ($roleDef['displayName'] ?? '') : null;
|
||||
|
||||
$severity = $this->catalog->classify($templateId, $displayName);
|
||||
|
||||
if ($severity !== null) {
|
||||
$principal = $assignment['principal'] ?? [];
|
||||
|
||||
$highPrivilegeAssignments[] = [
|
||||
'role_definition_id' => $roleDefId,
|
||||
'role_template_id' => (string) ($roleDef['templateId'] ?? ''),
|
||||
'role_display_name' => (string) ($roleDef['displayName'] ?? 'Unknown'),
|
||||
'is_built_in' => (bool) ($roleDef['isBuiltIn'] ?? false),
|
||||
'principal_id' => (string) ($assignment['principalId'] ?? ''),
|
||||
'principal_display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
|
||||
'principal_type' => $this->resolvePrincipalType($principal),
|
||||
'directory_scope_id' => (string) ($assignment['directoryScopeId'] ?? '/'),
|
||||
'severity' => $severity,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'provider_key' => 'microsoft',
|
||||
'domain' => 'entra.admin_roles',
|
||||
'measured_at' => CarbonImmutable::now('UTC')->toIso8601String(),
|
||||
'role_definitions' => $roleDefinitions,
|
||||
'role_assignments' => $roleAssignments,
|
||||
'totals' => [
|
||||
'roles_total' => count($roleDefinitions),
|
||||
'assignments_total' => count($roleAssignments),
|
||||
'high_privilege_assignments' => count($highPrivilegeAssignments),
|
||||
],
|
||||
'high_privilege' => $highPrivilegeAssignments,
|
||||
];
|
||||
}
|
||||
|
||||
private function computeFingerprint(array $roleDefinitions, array $roleAssignments): string
|
||||
{
|
||||
$roleDefMap = [];
|
||||
|
||||
foreach ($roleDefinitions as $def) {
|
||||
$id = (string) ($def['id'] ?? '');
|
||||
|
||||
if ($id !== '') {
|
||||
$roleDefMap[$id] = $def;
|
||||
}
|
||||
}
|
||||
|
||||
$tuples = [];
|
||||
|
||||
foreach ($roleAssignments as $assignment) {
|
||||
$roleDefId = (string) ($assignment['roleDefinitionId'] ?? '');
|
||||
$roleDef = $roleDefMap[$roleDefId] ?? null;
|
||||
$templateId = (string) ($roleDef['templateId'] ?? $roleDefId);
|
||||
$principalId = (string) ($assignment['principalId'] ?? '');
|
||||
$scopeId = (string) ($assignment['directoryScopeId'] ?? '/');
|
||||
|
||||
$tuples[] = "{$templateId}:{$principalId}:{$scopeId}";
|
||||
}
|
||||
|
||||
sort($tuples);
|
||||
|
||||
return hash('sha256', implode("\n", $tuples));
|
||||
}
|
||||
|
||||
private function resolvePrincipalType(array $principal): string
|
||||
{
|
||||
$odataType = (string) ($principal['@odata.type'] ?? '');
|
||||
|
||||
return match (true) {
|
||||
str_contains($odataType, 'user') => 'user',
|
||||
str_contains($odataType, 'group') => 'group',
|
||||
str_contains($odataType, 'servicePrincipal') => 'servicePrincipal',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
76
app/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.php
Normal file
76
app/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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 match.
|
||||
*
|
||||
* @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',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if (isset(self::CATALOG[$templateIdOrId])) {
|
||||
return self::CATALOG[$templateIdOrId];
|
||||
}
|
||||
|
||||
if ($displayName !== null) {
|
||||
$normalized = strtolower(trim($displayName));
|
||||
|
||||
if (isset(self::DISPLAY_NAME_FALLBACK[$normalized])) {
|
||||
return self::DISPLAY_NAME_FALLBACK[$normalized];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isHighPrivilege(string $templateIdOrId, ?string $displayName = null): bool
|
||||
{
|
||||
return $this->classify($templateIdOrId, $displayName) !== null;
|
||||
}
|
||||
|
||||
public function isGlobalAdministrator(string $templateIdOrId, ?string $displayName = null): bool
|
||||
{
|
||||
return $this->classify($templateIdOrId, $displayName) === 'critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> All template_id → severity mappings
|
||||
*/
|
||||
public function allTemplateIds(): array
|
||||
{
|
||||
return self::CATALOG;
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,10 @@ public function __construct(
|
||||
*/
|
||||
public function getRequiredPermissions(): array
|
||||
{
|
||||
return config('intune_permissions.permissions', []);
|
||||
return array_merge(
|
||||
config('intune_permissions.permissions', []),
|
||||
config('entra_permissions.permissions', []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -104,6 +104,11 @@ class Capabilities
|
||||
// Audit
|
||||
public const AUDIT_VIEW = 'audit.view';
|
||||
|
||||
// Entra admin roles
|
||||
public const ENTRA_ROLES_VIEW = 'entra_roles.view';
|
||||
|
||||
public const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
|
||||
@ -18,6 +18,7 @@ public function spec(mixed $value): BadgeSpec
|
||||
return match ($state) {
|
||||
Finding::FINDING_TYPE_DRIFT => new BadgeSpec('Drift', 'info', 'heroicon-m-arrow-path'),
|
||||
Finding::FINDING_TYPE_PERMISSION_POSTURE => new BadgeSpec('Permission posture', 'warning', 'heroicon-m-shield-exclamation'),
|
||||
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,6 +54,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->isWorkspaceScopedPageWithTenant($refererPath)) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
@ -124,6 +130,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($this->isWorkspaceScopedPageWithTenant($path)) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||
@ -244,6 +256,11 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
});
|
||||
}
|
||||
|
||||
private function isWorkspaceScopedPageWithTenant(string $path): bool
|
||||
{
|
||||
return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function adminPathRequiresTenantSelection(string $path): bool
|
||||
{
|
||||
if (! str_starts_with($path, '/admin/')) {
|
||||
|
||||
@ -50,6 +50,7 @@ public static function labels(): array
|
||||
'baseline_capture' => 'Baseline capture',
|
||||
'baseline_compare' => 'Baseline compare',
|
||||
'permission_posture_check' => 'Permission posture check',
|
||||
'entra.admin_roles.scan' => 'Entra admin roles scan',
|
||||
];
|
||||
}
|
||||
|
||||
@ -80,6 +81,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'baseline_capture' => 120,
|
||||
'baseline_compare' => 120,
|
||||
'permission_posture_check' => 30,
|
||||
'entra.admin_roles.scan' => 60,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ enum OperationRunType: string
|
||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
|
||||
@ -33,6 +33,9 @@ public static function all(): array
|
||||
'findings_unchanged',
|
||||
'errors_recorded',
|
||||
'posture_score',
|
||||
'report_created',
|
||||
'report_deduped',
|
||||
'alert_events_produced',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,13 +57,25 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
||||
$parts = [];
|
||||
|
||||
foreach ($normalized as $key => $value) {
|
||||
$parts[] = $key.': '.$value;
|
||||
if ($value === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = self::humanizeKey($key).': '.$value;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Summary: '.implode(', ', $parts);
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a snake_case summary key to a human-readable label.
|
||||
*/
|
||||
private static function humanizeKey(string $key): string
|
||||
{
|
||||
return ucfirst(str_replace('_', ' ', $key));
|
||||
}
|
||||
}
|
||||
|
||||
12
config/entra_permissions.php
Normal file
12
config/entra_permissions.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -67,6 +67,16 @@
|
||||
'allowed_select' => ['id', 'displayName', 'resourceScopes', 'members'],
|
||||
'allowed_expand' => ['roleDefinition($select=id,displayName)'],
|
||||
],
|
||||
'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'],
|
||||
],
|
||||
'deviceConfiguration' => [
|
||||
'resource' => 'deviceManagement/deviceConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
|
||||
@ -68,4 +68,28 @@ public function resolved(): static
|
||||
'resolved_reason' => 'permission_granted',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Entra admin roles findings.
|
||||
*/
|
||||
public function entraAdminRoles(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'source' => 'entra.admin_roles',
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'subject_type' => 'role_assignment',
|
||||
'evidence_jsonb' => [
|
||||
'role_display_name' => 'Global Administrator',
|
||||
'principal_display_name' => 'Admin User',
|
||||
'principal_type' => 'user',
|
||||
'principal_id' => fake()->uuid(),
|
||||
'role_definition_id' => fake()->uuid(),
|
||||
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'directory_scope_id' => '/',
|
||||
'is_built_in' => true,
|
||||
'measured_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('stored_reports', function (Blueprint $table) {
|
||||
$table->string('fingerprint', 64)->nullable()->after('payload');
|
||||
$table->string('previous_fingerprint', 64)->nullable()->after('fingerprint');
|
||||
|
||||
$table->unique(['tenant_id', 'report_type', 'fingerprint']);
|
||||
$table->index(['tenant_id', 'report_type', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('stored_reports', function (Blueprint $table) {
|
||||
$table->dropIndex(['tenant_id', 'report_type', 'created_at']);
|
||||
$table->dropUnique(['tenant_id', 'report_type', 'fingerprint']);
|
||||
$table->dropColumn(['fingerprint', 'previous_fingerprint']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
@php
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
/** @var ?array $reportSummary */
|
||||
/** @var ?string $lastScanAt */
|
||||
/** @var int $highPrivilegeCount */
|
||||
/** @var bool $canManage */
|
||||
/** @var bool $canView */
|
||||
/** @var ?string $viewReportUrl */
|
||||
@endphp
|
||||
|
||||
<x-filament::section heading="Entra admin roles">
|
||||
<x-slot name="afterHeader">
|
||||
@if ($canView && $viewReportUrl)
|
||||
<a
|
||||
href="{{ $viewReportUrl }}"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
View latest report
|
||||
</a>
|
||||
@endif
|
||||
</x-slot>
|
||||
|
||||
@if ($reportSummary === null)
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No scan performed yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last scan
|
||||
</div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ $lastScanAt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
High-privilege assignments
|
||||
</div>
|
||||
<div class="text-sm font-medium {{ $highPrivilegeCount > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
|
||||
{{ $highPrivilegeCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($canManage)
|
||||
<div class="mt-4">
|
||||
<x-filament::button
|
||||
wire:click="scanNow"
|
||||
size="sm"
|
||||
color="primary"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
<span wire:loading.remove wire:target="scanNow">Scan now</span>
|
||||
<span wire:loading wire:target="scanNow">Scanning…</span>
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
use App\Jobs\PruneOldOperationRunsJob;
|
||||
use App\Jobs\ReconcileAdapterRunsJob;
|
||||
use App\Jobs\ScanEntraAdminRolesJob;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@ -31,3 +33,22 @@
|
||||
->daily()
|
||||
->name('stored-reports:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::call(function (): void {
|
||||
$tenants = Tenant::query()
|
||||
->whereHas('providerConnections', function ($q): void {
|
||||
$q->where('status', 'connected');
|
||||
})
|
||||
->whereNotNull('workspace_id')
|
||||
->get();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
}
|
||||
})
|
||||
->daily()
|
||||
->name('entra-admin-roles:scan')
|
||||
->withoutOverlapping();
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
# Specification Quality Checklist: Entra Admin Roles Evidence + Findings
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to implementation
|
||||
**Created**: 2026-02-21
|
||||
**Last Validated**: 2026-02-22 (post-analysis remediation)
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs) in user stories
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders (user stories section)
|
||||
- [x] All mandatory sections completed (Scope Fields, User Scenarios, Requirements, Success Criteria, UI Action Matrix)
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous (21 FRs, each with MUST + verifiable condition)
|
||||
- [x] Success criteria are measurable (SC-001 through SC-008 with quantitative metrics)
|
||||
- [x] Success criteria are technology-agnostic (no framework/language references)
|
||||
- [x] All acceptance scenarios are defined (6 user stories with 18 total acceptance scenarios)
|
||||
- [x] Edge cases are identified (8 documented: partial data, service principals, group-assigned roles, scoped assignments, missing template_id, zero assignments, concurrent scans, threshold hardcode)
|
||||
- [x] Scope is clearly bounded (Non-Goals section: no PIM, no remediation, no EvidenceItems, no RBAC refactor)
|
||||
- [x] Dependencies and assumptions identified (Spec 104, Spec 099, Findings model, Graph RBAC API, no PIM, StoredReports retention)
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria (FRs map to acceptance scenarios in user stories)
|
||||
- [x] User scenarios cover primary flows (scan → report → findings → alerts → UI)
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification (entities section describes domain concepts, not code)
|
||||
|
||||
## Spec ↔ Plan ↔ Tasks Consistency (post-analysis)
|
||||
|
||||
- [x] No stale clarifications contradict plan/tasks (I1 remediated: migration Q&A corrected)
|
||||
- [x] All 21 FRs have ≥1 implementation task (FR-021 is test-only; T035 confirms viewer rendering assumption first)
|
||||
- [x] All success criteria are testable by ≥1 task (SC-005 now covered by T028(5); SC-006 backed by retention assumption; SC-008 deferred to staging)
|
||||
- [x] Edge case: scoped assignments tested (T025(15) added)
|
||||
- [x] Edge case: group = 1 principal tested (T025(11) covers principal types including group)
|
||||
- [x] Posture score impact tested (T028(5) added)
|
||||
- [x] Phase dependency D1 noted as advisory — US4 can start after Phase 2 (not blocked on Phase 4)
|
||||
|
||||
## Constitution Alignment
|
||||
|
||||
- [x] Constitution alignment (required) — Graph contracts, safety gates, tenant isolation, run observability, tests
|
||||
- [x] Constitution alignment (RBAC-UX) — authorization planes, 404/403 semantics, capability registry, authorization tests
|
||||
- [x] Constitution alignment (OPS-EX-AUTH-001) — N/A documented
|
||||
- [x] Constitution alignment (BADGE-001) — new finding type badge documented with test (T014)
|
||||
- [x] Constitution alignment (Filament Action Surfaces) — UI Action Matrix completed; widget exemption documented
|
||||
- [x] Constitution alignment (UX-001) — Exemption for no new Create/Edit pages documented
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec + plan + tasks are consistent and ready for `/speckit.implement`.
|
||||
- Plan.md and tasks.md have been written and validated against spec.
|
||||
- High-Privilege Role Catalog includes Microsoft well-known template IDs for v1 classification.
|
||||
- "Too many Global Admins" threshold is hardcoded at 5 with documented TODO for settings migration.
|
||||
- SC-008 (scan performance ≤30s / 200 assignments) is not testable in standard Pest suite — validate on staging.
|
||||
- FR-021 (report viewer) assumes existing viewer handles `entra.admin_roles` payload; T035 confirms this first.
|
||||
- StoredReports retention (default 90 days) is assumed to be handled by existing infrastructure (documented in Assumptions).
|
||||
@ -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[]
|
||||
```
|
||||
219
specs/105-entra-admin-roles-evidence-findings/plan.md
Normal file
219
specs/105-entra-admin-roles-evidence-findings/plan.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Implementation Plan: Entra Admin Roles Evidence + Findings
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**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
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- [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).
|
||||
|
||||
**Post-Phase-1 re-check**: All items pass. No violations found.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
config/
|
||||
├── entra_permissions.php # NEW: Entra permission registry
|
||||
└── graph_contracts.php # MODIFIED: +entraRoleDefinitions, +entraRoleAssignments
|
||||
|
||||
database/
|
||||
├── migrations/
|
||||
│ └── XXXX_add_fingerprint_to_stored_reports.php # NEW: fingerprint + previous_fingerprint columns
|
||||
└── factories/
|
||||
└── FindingFactory.php # MODIFIED: +entraAdminRoles() state
|
||||
|
||||
resources/views/filament/widgets/tenant/
|
||||
└── admin-roles-summary.blade.php # NEW: card template
|
||||
|
||||
routes/
|
||||
└── console.php # MODIFIED: schedule daily admin roles scan
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations. No complexity justifications needed.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A — Foundation (Migration + Constants + Config)
|
||||
|
||||
**Goal**: Establish the data layer, config, and constants that all other phases depend on.
|
||||
|
||||
**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
|
||||
|
||||
**Dependencies**: None (foundation layer).
|
||||
|
||||
### Phase B — High-Privilege Catalog + Report Service
|
||||
|
||||
**Goal**: Implement role classification and the Graph-backed report generation service.
|
||||
|
||||
**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)
|
||||
|
||||
**Dependencies**: Phase A (constants, config, migration).
|
||||
|
||||
### Phase C — Finding Generator
|
||||
|
||||
**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
|
||||
|
||||
**Dependencies**: Phase B (catalog, report service provides payload structure).
|
||||
|
||||
### Phase D — Scan Job + Scheduling + Permission Posture Integration
|
||||
|
||||
**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
|
||||
|
||||
**Dependencies**: Phase C (finding generator).
|
||||
|
||||
### Phase E — Alerts Integration
|
||||
|
||||
**Goal**: Connect admin roles findings to the existing alert pipeline.
|
||||
|
||||
**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
|
||||
|
||||
**Dependencies**: Phase D (job produces findings that generate alert events).
|
||||
|
||||
### Phase F — Tenant Dashboard Widget
|
||||
|
||||
**Goal**: Provide a tenant dashboard card for admin roles posture at-a-glance.
|
||||
|
||||
**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`
|
||||
|
||||
**Dependencies**: Phases A-D complete (job, reports, constants).
|
||||
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
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.
|
||||
352
specs/105-entra-admin-roles-evidence-findings/spec.md
Normal file
352
specs/105-entra-admin-roles-evidence-findings/spec.md
Normal file
@ -0,0 +1,352 @@
|
||||
# Feature Specification: Entra Admin Roles Evidence + Findings
|
||||
|
||||
**Feature Branch**: `105-entra-admin-roles-evidence-findings`
|
||||
**Created**: 2026-02-21
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Entra Admin Roles Evidence + Findings (StoredReports + Alerts) — auditierbar beantworten: wer hat welche Entra Admin-Rollen, welche sind high privilege, hat sich die Lage verändert (drift), und kann ich das als Evidence snapshotten und als Findings/Alerts operationalisieren."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (per-tenant Entra role assignment posture) + workspace (alerts infrastructure extends workspace-level)
|
||||
- **Primary Routes**:
|
||||
- No new standalone Filament pages in this spec
|
||||
- Existing: Monitoring > Findings (extended with `finding_type=entra_admin_roles`)
|
||||
- Existing: Monitoring > Alerts (extended with new `entra.admin_roles.high` event type)
|
||||
- Existing: Tenant dashboard card showing admin roles summary (new card widget)
|
||||
- Existing: Stored Reports viewer filtered to `report_type=entra.admin_roles`
|
||||
- **Data Ownership**:
|
||||
- `stored_reports` — tenant-owned, reused from Spec 104 (new `report_type=entra.admin_roles`)
|
||||
- `findings` — tenant-owned, extended with `finding_type=entra_admin_roles` (existing table)
|
||||
- `config/entra_permissions.php` — new registry file for Entra-specific Graph permissions (workspace infra)
|
||||
- **RBAC**:
|
||||
- Workspace membership required for any access (non-members → 404)
|
||||
- New capabilities: `ENTRA_ROLES_VIEW` (see Admin Roles card + report viewer), `ENTRA_ROLES_MANAGE` (trigger on-demand scan)
|
||||
- `entra_admin_roles` findings in the Findings list are gated by existing `FINDINGS_VIEW` — `ENTRA_ROLES_VIEW` is NOT required to see them
|
||||
- Readonly/Operator roles → `ENTRA_ROLES_VIEW`
|
||||
- Manager/Owner roles → `ENTRA_ROLES_MANAGE`
|
||||
- Findings remain gated by existing `TENANT_FINDINGS_ACKNOWLEDGE` for acknowledge actions
|
||||
|
||||
## Assumptions and Dependencies
|
||||
|
||||
- **Spec 104 (Provider Permission Posture)** established the `stored_reports` table, `StoredReport` model, fingerprint-based dedupe, and the posture report pattern. This spec reuses that infrastructure with a new `report_type`.
|
||||
- **StoredReports retention** is handled by existing infrastructure and defaults to 90 days.
|
||||
- **Alerts v1 (Spec 099)** provides the generic `AlertDispatchService`, `EvaluateAlertsJob` framework, and `AlertRule` model. This spec adds a new event type constant; no structural changes to alerting.
|
||||
- **Findings model** already supports `finding_type`, `source`, `subject_type`, `subject_external_id`, severity, status (including `resolved`), and fingerprint-based idempotent upsert (established by drift findings + Spec 104).
|
||||
- **`config/intune_permissions.php`** is the existing registry for Intune-specific Graph permissions. A new `config/entra_permissions.php` will be created for Entra Directory permissions. The registry loader (`TenantPermissionService` or equivalent) must merge both sources.
|
||||
- **Microsoft Graph unified RBAC API** provides `roleManagement/directory/roleDefinitions` and `roleManagement/directory/roleAssignments?$expand=principal` for reading directory role data.
|
||||
- **No PIM/Eligible Assignments** in v1 — only active (permanent) role assignments are covered.
|
||||
- **No automatic remediation** — this spec covers evidence collection, findings, and alerts only.
|
||||
|
||||
## User Scenarios and Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Scan Entra admin role assignments and produce evidence snapshot (Priority: P1)
|
||||
|
||||
As a workspace operator, I want the system to scan a tenant's Entra directory role definitions and active role assignments, then persist the results as a stored report, so that I have an auditable snapshot of who holds which admin roles at any point in time.
|
||||
|
||||
**Why this priority**: This is the foundational data pipeline. Without fetching and persisting role assignment data, no findings, alerts, or UI can function.
|
||||
|
||||
**Independent Test**: Trigger an on-demand scan for a tenant; confirm a `stored_report` record exists with `report_type=entra.admin_roles`, a valid payload containing `role_definitions`, `role_assignments`, `totals`, and a content-based fingerprint.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has a configured provider connection with `RoleManagement.Read.Directory` permission, **When** the admin roles scan runs, **Then** a `stored_report` is created with `report_type=entra.admin_roles`, `tenant_id` set, and a JSONB payload containing `role_definitions`, `role_assignments`, and `totals`.
|
||||
2. **Given** the scan runs twice with identical role assignment data, **When** the second scan completes, **Then** no duplicate stored report is created (fingerprint-based dedupe).
|
||||
3. **Given** a role assignment has changed between scans (e.g., new Global Admin added), **When** the second scan completes, **Then** a new stored report is created with a different fingerprint, and `previous_fingerprint` references the prior report's fingerprint.
|
||||
4. **Given** the tenant has no provider connection, **When** the scan is dispatched, **Then** the job completes gracefully with no stored report or findings created.
|
||||
5. **Given** the Graph API is unreachable during the scan, **When** the job fails, **Then** the `OperationRun` records the failure with error details and no stored report or findings are created.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Generate findings for high-privilege role assignments (Priority: P1)
|
||||
|
||||
As a compliance officer, I want the system to automatically generate findings for high-privilege Entra admin role assignments, so that I can track privileged access posture and identify excessive admin patterns.
|
||||
|
||||
**Why this priority**: Findings are the actionable output of the scan. Without them, there is no governance signal.
|
||||
|
||||
**Independent Test**: Run the finding generator for a tenant with a Global Administrator assignment; confirm a finding with `finding_type=entra_admin_roles`, `severity=critical`, and the correct fingerprint exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has 3 users assigned to "Global Administrator", **When** the finding generator runs, **Then** 3 individual findings are created (one per principal per role), each with `finding_type=entra_admin_roles`, `severity=critical`, and a deterministic fingerprint.
|
||||
2. **Given** a tenant has a user assigned to "Security Administrator" (high privilege, not Global Admin), **When** the finding generator runs, **Then** a finding is created with `severity=high`.
|
||||
3. **Given** a previously-flagged principal's high-privilege role assignment is removed, **When** the next scan runs the finding generator, **Then** the corresponding finding is auto-resolved (`status=resolved`, `resolved_at` set, `resolved_reason=role_assignment_removed`).
|
||||
4. **Given** the same principal still holds the same high-privilege role across two scans, **When** the finding generator runs again, **Then** no duplicate finding is created (fingerprint idempotency); `times_seen` and `last_seen_at` are updated.
|
||||
5. **Given** a tenant has more than 5 principals assigned to "Global Administrator", **When** the finding generator runs, **Then** an aggregate finding "Too many Global Admins" is created with `severity=high` and a distinct fingerprint.
|
||||
6. **Given** a resolved finding's principal is re-assigned the same high-privilege role, **When** the finding generator runs, **Then** the finding is re-opened (`status=new`, `resolved_at`/`resolved_reason` cleared, evidence updated).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Alert on high-privilege admin role events (Priority: P2)
|
||||
|
||||
As a workspace manager, I want to receive alerts (Teams/email) when new high-privilege Entra admin role assignments are detected, so I can react to privilege escalation without polling the UI.
|
||||
|
||||
**Why this priority**: Alerts are the push-notification layer. They close the feedback loop for time-sensitive governance signals.
|
||||
|
||||
**Independent Test**: Create an alert rule for the new event type, run the finding generator with a new Global Admin assignment, and confirm a delivery is queued.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an alert rule exists for `entra.admin_roles.high` with minimum severity = high, **When** a new high-privilege finding is created, **Then** a delivery is queued for each enabled destination on that rule.
|
||||
2. **Given** an alert rule exists with minimum severity = critical, **When** a finding with severity = high (not critical) is created, **Then** no delivery is queued.
|
||||
3. **Given** the same finding persists across scans (no change), **When** the alert evaluator runs, **Then** cooldown/dedupe logic prevents duplicate notifications.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Entra permissions appear in permission posture (Priority: P2)
|
||||
|
||||
As a workspace operator, I want the new Entra-specific Graph permissions (`RoleManagement.Read.Directory`) to appear in the permission posture checks, so that the posture score accurately reflects whether a tenant can run admin role scans.
|
||||
|
||||
**Why this priority**: Without this, Spec 104's posture score would silently ignore whether the Entra roles feature is permission-ready — the posture would "lie."
|
||||
|
||||
**Independent Test**: Verify that after loading the merged registry (Intune + Entra), the required permission `RoleManagement.Read.Directory` appears in the list. Verify posture score computation includes it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** `config/entra_permissions.php` defines `RoleManagement.Read.Directory` as required, **When** the registry loader runs, **Then** the merged required-permissions list includes the Entra entry alongside all Intune entries.
|
||||
2. **Given** a tenant does not have `RoleManagement.Read.Directory` granted, **When** the permission posture generator runs, **Then** a posture finding is created for that missing permission, and the posture score reflects the gap.
|
||||
3. **Given** a tenant has all Intune + Entra permissions granted, **When** posture runs, **Then** the posture score is 100.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — View admin roles summary on tenant dashboard (Priority: P3)
|
||||
|
||||
As a workspace operator, I want to see a summary card on the tenant dashboard displaying the latest admin roles scan timestamp and high-privilege assignment count, so I can quickly assess a tenant's admin roles posture at a glance.
|
||||
|
||||
**Why this priority**: UI visibility provides daily governance context. Lower priority because the backend data and findings are more critical.
|
||||
|
||||
**Independent Test**: Navigate to a tenant dashboard after a scan has completed; confirm the card shows the correct timestamp and high-privilege count drawn from the latest stored report.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has a stored report with `report_type=entra.admin_roles`, **When** I view the tenant dashboard, **Then** the "Admin Roles" card displays the last scan timestamp and high-privilege assignment count from the latest report.
|
||||
2. **Given** a tenant has no admin roles report yet, **When** I view the tenant dashboard, **Then** the card shows "No scan performed" with a "Scan now" CTA (gated by `ENTRA_ROLES_MANAGE`).
|
||||
3. **Given** I click "Scan now" and have `ENTRA_ROLES_MANAGE`, **When** the button is pressed, **Then** a new `entra.admin_roles.scan` OperationRun is dispatched and the UI shows a pending state.
|
||||
4. **Given** I have `ENTRA_ROLES_VIEW` but not `ENTRA_ROLES_MANAGE`, **When** I view the card, **Then** the "Scan now" button is not visible and the scan action endpoint returns 403.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 — View full admin roles report (Priority: P3)
|
||||
|
||||
As a compliance officer, I want to view the full admin roles report (table of all high-privilege assignments, totals, role definitions) for a specific tenant, so I can audit detailed role assignment state.
|
||||
|
||||
**Why this priority**: Detailed report viewing supports audit workflows but is not required for core posture detection.
|
||||
|
||||
**Independent Test**: Navigate to the stored reports viewer filtered by `entra.admin_roles` for a tenant; confirm the report displays a table of high-privilege assignments with principal names, roles, and scope.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has a stored report with `report_type=entra.admin_roles`, **When** I view the report, **Then** I see a summary (totals) and a table of high-privilege role assignments with principal display name, principal type, role display name, and directory scope.
|
||||
2. **Given** multiple reports exist over time, **When** I list reports, **Then** they are ordered by creation date descending and I can navigate between them.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Graph API returns partial data**: If `roleDefinitions` succeeds but `roleAssignments` fails (or vice versa), the scan MUST fail the OperationRun (no partial reports). All-or-nothing per scan.
|
||||
- **Service principal assignments**: Some high-privilege role assignments go to service principals, not users. The finding generator MUST handle all principal types (`user`, `group`, `servicePrincipal`) and include the principal type in evidence.
|
||||
- **Group-assigned roles**: When a group holds a high-privilege role, it counts as one principal (no member expansion in v1). The finding is created for the group itself. The aggregate "Too many Global Admins" threshold counts assignment records, not expanded members. Evidence records `principal.type=group` so operators can identify group-based assignments for manual review.
|
||||
- **Scoped role assignments**: Some role assignments are scoped to administrative units (`directory_scope_id != "/"`). The high-privilege finding still applies — scope is recorded in evidence but does not downgrade severity in v1.
|
||||
- **Role definitions without `template_id`**: Custom role definitions may not have a `template_id`. The `HighPrivilegeRoleCatalog` falls back to `display_name` matching for classification. If neither matches, the role is not flagged as high-privilege.
|
||||
- **Tenant with zero role assignments**: Possible for app-only tenants. The scan produces a valid report with `assignments_total=0` and no findings.
|
||||
- **Concurrent scans for same tenant**: OperationRun active-run uniqueness constraint (per workspace_id, tenant_id, run_type) prevents duplicate concurrent scans.
|
||||
- **Threshold for "Too many Global Admins"**: v1 hardcodes threshold at 5. A `TODO` comment marks this for future settings-based configuration.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces Microsoft Graph calls and scheduled/queued work.
|
||||
- **Contract registry**: New Graph endpoints registered in `config/graph_contracts.php`:
|
||||
- `GET /roleManagement/directory/roleDefinitions`
|
||||
- `GET /roleManagement/directory/roleAssignments?$expand=principal`
|
||||
- **Safety gates**: Scan is a read-only operation against Entra (no writes). No confirmation required for scan dispatch — it's non-destructive.
|
||||
- **Tenant isolation**: Stored reports, findings, and OperationRuns are always scoped to a specific tenant via `tenant_id` FK. Cross-tenant data access is impossible at the query level.
|
||||
- **Run observability**: Each scan execution is tracked as an `OperationRun` with `type=entra.admin_roles.scan`, recording status, outcome, started_at, completed_at, and error details.
|
||||
- **Tests**: Unit tests for report generation, finding generation, high-privilege classification, auto-resolve, alert event production, and registry merge.
|
||||
|
||||
**Constitution alignment (RBAC-UX):**
|
||||
- **Authorization planes**: Tenant-context (`/admin/t/{tenant}/...`) for viewing reports and findings; workspace-context for alerts.
|
||||
- **404 vs 403**: Non-member / not entitled to workspace or tenant scope → 404 (deny-as-not-found). Member missing `ENTRA_ROLES_VIEW` → 403 for Admin Roles card/report. Member missing `ENTRA_ROLES_MANAGE` → 403 for scan. Findings list uses existing `FINDINGS_VIEW` (no `ENTRA_ROLES_VIEW` required).
|
||||
- **Server-side enforcement**: New policy for on-demand scan action; existing `FindingPolicy` covers findings (gated by `FINDINGS_VIEW`, not `ENTRA_ROLES_VIEW`).
|
||||
- **Capability registry**: New constants `ENTRA_ROLES_VIEW` and `ENTRA_ROLES_MANAGE` added to `App\Support\Auth\Capabilities`.
|
||||
- **Global search**: No new globally searchable resources.
|
||||
- **Destructive actions**: None. Scans are read-only; findings are system-generated.
|
||||
- **Authorization tests**: Positive test (user with `ENTRA_ROLES_VIEW` can see report; user with `ENTRA_ROLES_MANAGE` can trigger scan) and negative test (user without capability gets 403; non-member gets 404).
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no OIDC/SAML login flows involved.
|
||||
|
||||
**Constitution alignment (BADGE-001):**
|
||||
- `finding_type=entra_admin_roles` — new finding type value added to centralized `FindingTypeBadge` mapper with appropriate icon and color.
|
||||
- `severity` values: Uses existing `low`, `medium`, `high`, `critical` — no new severity values.
|
||||
- `status` values: Uses existing `new`, `acknowledged`, `resolved` — no new status values.
|
||||
- Tests cover rendering of `entra_admin_roles` type badge.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This spec adds a small tenant card widget ("Admin Roles") with a "Scan now" header action and a "View latest report" link. No new full Resources or RelationManagers.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout and Information Architecture):**
|
||||
- The tenant card widget follows existing card widget conventions (summary stat + CTA).
|
||||
- Report viewer reuses the existing stored reports viewer layout (existing UX-001-compliant screen).
|
||||
- **Exemption**: No new Create/Edit pages — scan is a single-action trigger. No form layout applies.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Entra Permissions Registry)**: The system MUST provide a `config/entra_permissions.php` registry file following the same schema as `config/intune_permissions.php`, declaring `RoleManagement.Read.Directory` (type: application) as required for the `entra-admin-roles` feature. Optionally: `Directory.Read.All` as a fallback entry.
|
||||
- **FR-002 (Registry Merge)**: The registry loader (used by TenantPermissionService and posture generator) MUST merge entries from both `config/intune_permissions.php` and `config/entra_permissions.php` into a single required-permissions set. The merge MUST be non-breaking (existing Intune posture flows unchanged).
|
||||
- **FR-003 (Graph Contract Registration)**: New Graph endpoints MUST be registered in `config/graph_contracts.php`:
|
||||
- `GET /roleManagement/directory/roleDefinitions`
|
||||
- `GET /roleManagement/directory/roleAssignments?$expand=principal`
|
||||
- **FR-004 (Admin Roles Report Service)**: The system MUST provide `EntraAdminRolesReportService` that:
|
||||
- Fetches role definitions via Graph
|
||||
- Fetches role assignments (with `$expand=principal`) via Graph
|
||||
- Builds a structured payload (see Payload Schema below)
|
||||
- Computes a deterministic content fingerprint from sorted `(role_definition_template_id or id, principal_id, scope_id)` tuples
|
||||
- Writes a `stored_report` with `report_type=entra.admin_roles`, fingerprint, and `previous_fingerprint`
|
||||
- Deduplicates on identical fingerprint (no new report if data unchanged)
|
||||
- **FR-005 (Payload Schema)**: Stored report payload MUST contain: `provider_key`, `domain`, `measured_at`, `role_definitions` array, `role_assignments` array (with principal expansion), `totals` (roles_total, assignments_total, high_privilege_assignments), and `high_privilege` (definition_ids, count).
|
||||
- **FR-006 (High-Privilege Role Catalog)**: The system MUST provide a `HighPrivilegeRoleCatalog` class that classifies roles as high-privilege using a deterministic list. Classification MUST prefer `template_id` matching, falling back to `display_name` matching. v1 set: Global Administrator, Privileged Role Administrator, Security Administrator, Conditional Access Administrator, Exchange Administrator, Authentication Administrator.
|
||||
- **FR-007 (OperationRun Type)**: A new `OperationRunType` case `EntraAdminRolesScan` with value `entra.admin_roles.scan` MUST be added. Active-run uniqueness MUST be enforced per `(workspace_id, tenant_id, run_type)`.
|
||||
- **FR-008 (Scan Job)**: The system MUST provide a `ScanEntraAdminRolesJob` that:
|
||||
- Creates/updates an `OperationRun`
|
||||
- Calls `EntraAdminRolesReportService` to produce the report
|
||||
- Calls `EntraAdminRolesFindingGenerator` to produce findings
|
||||
- Records success/failure outcome on the OperationRun
|
||||
- **FR-009 (Finding Generator)**: The system MUST provide `EntraAdminRolesFindingGenerator` that creates findings with:
|
||||
- `finding_type = entra_admin_roles`
|
||||
- `source = entra.admin_roles`
|
||||
- For each high-privilege role assignment: one finding per (principal, role) pair
|
||||
- Severity: `critical` for Global Administrator, `high` for all other high-privilege roles
|
||||
- Fingerprint: `entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}`
|
||||
- Evidence: role display name, principal display name, principal type, scope, is_built_in
|
||||
- **FR-010 (Aggregate Finding — Too Many Global Admins)**: When the number of principals assigned to Global Administrator exceeds a threshold (v1 hardcoded: 5), the system MUST create an aggregate finding with:
|
||||
- Fingerprint: `entra_admin_role_ga_count:{tenant_id}`
|
||||
- Severity: `high`
|
||||
- Evidence: count, threshold, list of principal display names
|
||||
- **FR-011 (Idempotent Upsert)**: Finding generation MUST use fingerprint-based upsert to prevent duplicates. On repeated scans: `times_seen` and `last_seen_at` are updated. On data change: findings for removed assignments are auto-resolved; new assignments create new findings.
|
||||
- **FR-012 (Auto-Resolve)**: When a high-privilege role assignment no longer appears in the latest scan, the corresponding finding MUST be auto-resolved (`status=resolved`, `resolved_at` set, `resolved_reason=role_assignment_removed`). Auto-resolve applies to `new` and `acknowledged` findings alike (acknowledged metadata preserved).
|
||||
- **FR-013 (Re-Open)**: When a resolved finding's fingerprint matches a current assignment (re-assigned role), the finding MUST be re-opened (`status=new`, `resolved_at`/`resolved_reason` cleared, evidence updated).
|
||||
- **FR-014 (Alert Event)**: When high-privilege findings are created or re-opened, the system MUST produce events with `event_type=entra.admin_roles.high` for the alert dispatch pipeline. Events include tenant_id, finding fingerprint, severity. Resolved or unchanged findings do NOT produce alert events.
|
||||
- **FR-015 (Alert Rule Event Type)**: The `entra.admin_roles.high` event type MUST be available in the alert rule event type dropdown. Constant added to `AlertRule` model.
|
||||
- **FR-016 (Scan Scheduling)**: The scan MUST support two modes:
|
||||
- Scheduled: daily via workspace dispatcher (alongside existing scheduled scans)
|
||||
- On-demand: "Scan now" button on tenant dashboard (gated by `ENTRA_ROLES_MANAGE`)
|
||||
- **FR-017 (RBAC Capabilities)**: New capabilities `ENTRA_ROLES_VIEW` and `ENTRA_ROLES_MANAGE` MUST be added to the canonical `Capabilities` registry and mapped to roles (Readonly/Operator → view; Manager/Owner → manage).
|
||||
- **FR-018 (Skip Unconnected Tenants)**: Scan MUST gracefully skip tenants without a configured provider connection. No findings, reports, or error OperationRuns for unconnected tenants.
|
||||
- **FR-019 (Data Minimization)**: Stored report payload MUST contain only governance-relevant data: IDs, display names, principal types, and scopes. No tokens, secrets, or excessive PII.
|
||||
- **FR-020 (Tenant Card Widget)**: A tenant dashboard card MUST display: last scan timestamp, high-privilege assignment count, "Scan now" CTA (gated by capability), and "View latest report" link.
|
||||
- **FR-021 (Report Viewer)**: The existing stored reports viewer MUST support filtering by `report_type=entra.admin_roles` and display the report's summary + high-privilege assignments table.
|
||||
|
||||
## Payload Schema (stored_reports.payload)
|
||||
|
||||
```json
|
||||
{
|
||||
"provider_key": "microsoft",
|
||||
"domain": "entra",
|
||||
"measured_at": "2026-02-21T10:00:00Z",
|
||||
"role_definitions": [
|
||||
{
|
||||
"id": "...",
|
||||
"template_id": "...",
|
||||
"display_name": "Global Administrator",
|
||||
"is_built_in": true
|
||||
}
|
||||
],
|
||||
"role_assignments": [
|
||||
{
|
||||
"id": "...",
|
||||
"role_definition_id": "...",
|
||||
"directory_scope_id": "/",
|
||||
"principal": {
|
||||
"id": "...",
|
||||
"type": "user|group|servicePrincipal",
|
||||
"display_name": "..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"roles_total": 100,
|
||||
"assignments_total": 42,
|
||||
"high_privilege_assignments": 7
|
||||
},
|
||||
"high_privilege": {
|
||||
"definition_ids": ["..."],
|
||||
"assignments": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant Card "Admin Roles" (Widget) | Tenant Dashboard | "Scan now" (gated by `ENTRA_ROLES_MANAGE`, non-destructive → no confirmation) | N/A | N/A | N/A | "No scan performed — Scan now" (gated) | N/A | N/A | OperationRun tracks scan | Widget, not a full Resource |
|
||||
| Report Viewer (existing) | Stored Reports > View (filtered `entra.admin_roles`) | N/A (uses existing report viewer) | Table of high-privilege assignments | N/A | N/A | "No reports yet" | N/A | N/A | No (read-only) | Reuses existing viewer, no new page |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **StoredReport (extended)**: Existing model gains a new `report_type` constant `entra.admin_roles`. Payload schema documented above. Fingerprint-based dedupe and `previous_fingerprint` for drift detection.
|
||||
- **Finding (extended)**: Existing model gains `finding_type=entra_admin_roles` and `source=entra.admin_roles`. Each finding represents a high-privilege role assignment (per principal per role) or an aggregate condition (too many Global Admins). Uses existing severity/status/fingerprint infrastructure.
|
||||
- **AlertRule (extended)**: Existing model gains `EVENT_ENTRA_ADMIN_ROLES_HIGH` constant for `event_type=entra.admin_roles.high`. No schema change.
|
||||
- **HighPrivilegeRoleCatalog (new)**: A value object / catalog class that deterministically classifies Entra role definitions as high-privilege. Uses `template_id` (preferred) or `display_name` (fallback). Testable and extensible.
|
||||
- **EntraAdminRolesReportService (new)**: Service class responsible for fetching Graph data, building the payload, computing fingerprint, and writing the stored report.
|
||||
- **EntraAdminRolesFindingGenerator (new)**: Service class responsible for generating/upserting findings from report data.
|
||||
- **ScanEntraAdminRolesJob (new)**: Queued job orchestrating the OperationRun lifecycle, report service, and finding generator.
|
||||
- **OperationRunType (extended)**: Enum gains `EntraAdminRolesScan = 'entra.admin_roles.scan'`.
|
||||
- **Capabilities (extended)**: `ENTRA_ROLES_VIEW = 'entra_roles.view'` and `ENTRA_ROLES_MANAGE = 'entra_roles.manage'`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No PIM/Eligible Assignments (`RoleEligibilitySchedules` etc.) in v1.
|
||||
- No automatic remediation (only evidence + findings + alerts).
|
||||
- No new `EvidenceItems` model (comes later; we use `stored_reports`).
|
||||
- No tenancy/RBAC refactor.
|
||||
- No custom role definitions management.
|
||||
|
||||
## High-Privilege Role Classification (v1)
|
||||
|
||||
The `HighPrivilegeRoleCatalog` classifies roles using `template_id` (preferred) with fallback to `display_name`:
|
||||
|
||||
| Role Display Name | Template ID | Severity when assigned |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
This set can be extended later via workspace settings.
|
||||
|
||||
## Graph API Details
|
||||
|
||||
### Required Permissions (Application)
|
||||
|
||||
- **Least privilege (v1)**: `RoleManagement.Read.Directory`
|
||||
- **Fallback (if $expand=principal requires it)**: `Directory.Read.All`
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `GET /roleManagement/directory/roleDefinitions` — all role definitions for the directory
|
||||
- `GET /roleManagement/directory/roleAssignments?$expand=principal` — all active role assignments with principal details
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-21
|
||||
|
||||
- Q: Does this spec introduce new database tables? → A: No. Reuses `stored_reports` (Spec 104) and `findings` (existing).
|
||||
- Q: Does this spec require database migrations? → A: Yes — one migration adds `fingerprint` and `previous_fingerprint` (plus indexes) to `stored_reports` for report dedupe and chaining, and `OperationRunType` gains the `entra.admin_roles.scan` case.
|
||||
- Q: Is `$expand=principal` reliable for all principal types? → A: Yes — Graph returns principal details for users, groups, and service principals. If expansion fails for a specific principal, the assignment is still recorded with `principal.display_name = null` (evidence captures this gap).
|
||||
- Q: Why two capabilities (VIEW/MANAGE) instead of reusing existing findings capabilities? → A: The scan action is domain-specific (Entra roles) and should be independently gatable. Viewing the resulting findings still uses existing findings capabilities, but triggering the scan and viewing the admin roles report card uses the new capabilities.
|
||||
- Q: How is daily scheduling implemented? → A: Same pattern as existing scheduled scans — workspace dispatcher iterates tenants with active connections and dispatches `ScanEntraAdminRolesJob` per tenant.
|
||||
- Q: When a group is assigned a high-privilege role, does the finding generator expand group members or treat the group as one principal? → A: Group = 1 principal. No member expansion in v1. Findings and the aggregate GA threshold count reflect Graph roleAssignment records directly (1 assignment = 1 finding). Member expansion would require additional Graph calls + `GroupMember.Read.All` permission and is deferred to a future spec.
|
||||
- Q: Should `ENTRA_ROLES_VIEW` gate `entra_admin_roles` findings in the Findings list too, or only the Admin Roles card + report viewer? → A: Card + report only. `ENTRA_ROLES_VIEW` gates the Admin Roles dashboard card and the stored-report viewer. Findings of type `entra_admin_roles` in the Findings list remain gated by the existing `FINDINGS_VIEW` capability (no dual-gate).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Evidence completeness)**: For any tenant with a configured provider connection and `RoleManagement.Read.Directory` permission, 100% of active directory role assignments are captured in the stored report.
|
||||
- **SC-002 (Finding accuracy)**: The number of open `entra_admin_roles` findings for a tenant exactly matches the number of high-privilege role assignments in the latest scan (no duplicates, no omissions).
|
||||
- **SC-003 (Auto-resolve latency)**: When a high-privilege role assignment is removed, the corresponding finding is resolved within the next scan cycle.
|
||||
- **SC-004 (Alert delivery)**: When a new high-privilege finding is created and an active alert rule matches, a delivery is queued within 2 minutes.
|
||||
- **SC-005 (Posture integration)**: After deploying this spec, the permission posture score for tenants missing `RoleManagement.Read.Directory` reflects the gap (score decreases proportionally).
|
||||
- **SC-006 (Temporal audit)**: An operator can query stored reports to see a tenant's admin role posture at any point within the configured retention window (default 90 days).
|
||||
- **SC-007 (No duplicates)**: Repeated scans with identical data produce no duplicate findings or stored reports.
|
||||
- **SC-008 (Scan performance)**: A single tenant scan completes within 30 seconds for tenants with up to 200 role assignments.
|
||||
332
specs/105-entra-admin-roles-evidence-findings/tasks.md
Normal file
332
specs/105-entra-admin-roles-evidence-findings/tasks.md
Normal file
@ -0,0 +1,332 @@
|
||||
# Tasks: Entra Admin Roles Evidence + Findings
|
||||
|
||||
**Input**: Design documents from `/specs/105-entra-admin-roles-evidence-findings/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/internal-services.md, quickstart.md
|
||||
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). All user stories include test tasks.
|
||||
**Operations**: This feature introduces queued work (`ScanEntraAdminRolesJob`). Tasks include `OperationRun` creation via `OperationRunService::ensureRunWithIdentity()` with type `entra.admin_roles.scan`, and outcome tracking per constitution.
|
||||
**RBAC**: New capabilities introduced: `ENTRA_ROLES_VIEW` and `ENTRA_ROLES_MANAGE`. Tasks include:
|
||||
- Capability constants in `App\Support\Auth\Capabilities` (no raw strings)
|
||||
- `RoleCapabilityMap` mapping: Readonly/Operator → VIEW; Manager/Owner → MANAGE
|
||||
- Widget `canView()` gated by `ENTRA_ROLES_VIEW`; "Scan now" action gated by `ENTRA_ROLES_MANAGE` server-side
|
||||
- 404 vs 403 semantics: non-member/not entitled → 404; member missing capability → 403
|
||||
- Authorization plane: tenant-context (`/admin/t/{tenant}/...`)
|
||||
- No new globally searchable resources. Existing search behavior unchanged.
|
||||
- No destructive actions — scan is read-only, no `->requiresConfirmation()` needed
|
||||
- Positive + negative authorization tests in widget test (US5)
|
||||
**Filament UI Action Surfaces**: **Partial exemption** — no new Resources/Pages/RelationManagers. Only changes: (1) new tenant card widget with "Scan now" header action, (2) new option added to `AlertRuleResource` event type dropdown. Widget is not a full Resource — Action Surface Contract does not apply to simple stat/card widgets (documented in spec UI Action Matrix).
|
||||
**Filament UI UX-001**: **Exemption** — no new Create/Edit/View pages. Widget follows existing card conventions. Report viewer reuses existing stored reports viewer.
|
||||
**Badges**: Adds `entra_admin_roles` finding type to `FindingTypeBadge` per BADGE-001 (`BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification')`). Tests included.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4, US5, US6)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No project setup needed — existing Laravel project with all framework dependencies.
|
||||
|
||||
*Phase skipped.*
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundation (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Migration, config files, model constants, enum cases, capability registry, badge mappings, and factory states that ALL user stories depend on. Maps to plan Phase A.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
### Migration
|
||||
|
||||
- [X] T001 Create migration for adding `fingerprint` (string(64), nullable) and `previous_fingerprint` (string(64), nullable) columns to `stored_reports` table via `vendor/bin/sail artisan make:migration add_fingerprint_to_stored_reports_table --no-interaction`. Add unique index on `[tenant_id, report_type, fingerprint]` and index on `[tenant_id, report_type, created_at DESC]`. Columns are nullable because existing `permission_posture` reports (Spec 104) don't use fingerprinting. See `specs/105-entra-admin-roles-evidence-findings/data-model.md` migration section.
|
||||
|
||||
### Config Files
|
||||
|
||||
- [X] T002 [P] Create `config/entra_permissions.php` with permissions array containing `RoleManagement.Read.Directory` (type: application, features: `['entra-admin-roles']`). Follow exact schema of existing `config/intune_permissions.php`. See `specs/105-entra-admin-roles-evidence-findings/data-model.md` config section.
|
||||
- [X] T003 [P] Add `entraRoleDefinitions` and `entraRoleAssignments` entries to `config/graph_contracts.php`. `entraRoleDefinitions`: resource `roleManagement/directory/roleDefinitions`, allowed_select `[id, displayName, templateId, isBuiltIn]`. `entraRoleAssignments`: resource `roleManagement/directory/roleAssignments`, allowed_select `[id, roleDefinitionId, principalId, directoryScopeId]`, allowed_expand `[principal]`. See `specs/105-entra-admin-roles-evidence-findings/data-model.md` graph contracts section.
|
||||
|
||||
### Model & Enum Constants
|
||||
|
||||
- [X] T004 [P] Extend `StoredReport` model in `app/Models/StoredReport.php`: add `REPORT_TYPE_ENTRA_ADMIN_ROLES = 'entra.admin_roles'` constant, add `fingerprint` and `previous_fingerprint` to `$fillable` array. See `specs/105-entra-admin-roles-evidence-findings/data-model.md` StoredReport section.
|
||||
- [X] T005 [P] Extend `Finding` model in `app/Models/Finding.php`: add `FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles'` constant. No method changes — existing `resolve()`, `reopen()`, and fingerprint-based lookup are reused from Spec 104.
|
||||
- [X] T006 [P] Extend `AlertRule` model in `app/Models/AlertRule.php`: add `EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high'` constant.
|
||||
- [X] T007 [P] Add `EntraAdminRolesScan = 'entra.admin_roles.scan'` case to `app/Support/OperationRunType.php` enum.
|
||||
|
||||
### Capability Registry
|
||||
|
||||
- [X] T008 [P] Add `ENTRA_ROLES_VIEW = 'entra_roles.view'` and `ENTRA_ROLES_MANAGE = 'entra_roles.manage'` constants to `app/Support/Auth/Capabilities.php`.
|
||||
- [X] T009 [P] Update `app/Support/Auth/RoleCapabilityMap.php` to map new capabilities: Readonly/Operator → `ENTRA_ROLES_VIEW`; Manager/Owner → `ENTRA_ROLES_VIEW` + `ENTRA_ROLES_MANAGE`. Follow existing mapping patterns in the file.
|
||||
|
||||
### Badge Mapping
|
||||
|
||||
- [X] T010 [P] Add `entra_admin_roles` mapping to `app/Support/Badges/Domains/FindingTypeBadge.php`: `Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => new BadgeSpec('Entra admin roles', 'danger', 'heroicon-m-identification')`. Follow existing pattern from `permission_posture` and `drift` mappings.
|
||||
|
||||
### Factory State
|
||||
|
||||
- [X] T011 [P] Add `entraAdminRoles()` factory state to `database/factories/FindingFactory.php`: sets `finding_type → Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES`, `source → 'entra.admin_roles'`, `severity → Finding::SEVERITY_CRITICAL`, `subject_type → 'role_assignment'`, sample evidence in `evidence_jsonb` with `role_display_name`, `principal_display_name`, `principal_type`, `principal_id`, `role_definition_id`, `directory_scope_id`, `is_built_in`, `measured_at`.
|
||||
|
||||
### Verify & Test Foundation
|
||||
|
||||
- [X] T012 Run migration via `vendor/bin/sail artisan migrate` and verify `stored_reports` table has `fingerprint` and `previous_fingerprint` columns, unique index on `[tenant_id, report_type, fingerprint]`, and index on `[tenant_id, report_type, created_at DESC]`.
|
||||
- [X] T013 [P] Write StoredReport fingerprint tests in `tests/Feature/EntraAdminRoles/StoredReportFingerprintTest.php`: (1) `fingerprint` and `previous_fingerprint` columns are fillable and persist correctly, (2) unique index prevents duplicate `(tenant_id, report_type, fingerprint)` combinations, (3) nullable columns allow null values for existing reports without fingerprints.
|
||||
- [X] T014 [P] Write badge rendering test for `entra_admin_roles` type in `tests/Feature/EntraAdminRoles/FindingTypeBadgeTest.php` (or extend existing badge test file): `entra_admin_roles` renders with `danger` color and `heroicon-m-identification` icon.
|
||||
- [X] T015 [P] Write capabilities registry test in `tests/Feature/EntraAdminRoles/EntraPermissionsRegistryTest.php`: (1) `Capabilities::ENTRA_ROLES_VIEW` and `Capabilities::ENTRA_ROLES_MANAGE` constants exist, (2) `RoleCapabilityMap` maps VIEW to Readonly/Operator, MANAGE to Manager/Owner.
|
||||
|
||||
**Checkpoint**: Foundation ready — migration, config, constants, capabilities, badge, and factory in place. User story implementation can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Scan & Evidence Snapshot (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Fetch Entra directory role data from Graph, classify high-privilege roles, and persist as a fingerprinted stored report with deduplication. Maps to plan Phase B.
|
||||
|
||||
**Independent Test**: Trigger report generation for a tenant; confirm a `stored_report` exists with `report_type=entra.admin_roles`, valid payload (role_definitions, role_assignments, totals, high_privilege), and content-based fingerprint.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T016 [P] [US1] Create `HighPrivilegeRoleCatalog` in `app/Services/EntraAdminRoles/HighPrivilegeRoleCatalog.php` with static `CATALOG` map (6 template_id → severity pairs) and `DISPLAY_NAME_FALLBACK` map (case-insensitive). Methods: `classify(string $templateIdOrId, ?string $displayName): ?string` (returns severity or null), `isHighPrivilege(...)`: bool, `isGlobalAdministrator(...)`: bool, `allTemplateIds(): array`. Classification prefers `template_id`, falls back to `display_name`. See `specs/105-entra-admin-roles-evidence-findings/data-model.md` HighPrivilegeRoleCatalog section and spec FR-006.
|
||||
- [X] T017 [P] [US1] Create `EntraAdminRolesReportResult` value object in `app/Services/EntraAdminRoles/EntraAdminRolesReportResult.php` with `readonly` properties: `bool $created`, `?int $storedReportId`, `string $fingerprint`, `array $payload`. See `specs/105-entra-admin-roles-evidence-findings/data-model.md`.
|
||||
- [X] T018 [US1] Create `EntraAdminRolesReportService` in `app/Services/EntraAdminRoles/EntraAdminRolesReportService.php` with constructor injecting `GraphClientInterface` and `HighPrivilegeRoleCatalog`. Method `generate(Tenant $tenant, ?OperationRun $operationRun = null): EntraAdminRolesReportResult` must: (1) fetch `roleDefinitions` via Graph contract `entraRoleDefinitions`, (2) fetch `roleAssignments` with `$expand=principal` via contract `entraRoleAssignments`, (3) build payload per FR-005 schema (provider_key, domain, measured_at, role_definitions, role_assignments, totals, high_privilege), (4) compute fingerprint: SHA-256 of sorted `"{role_template_or_id}:{principal_id}:{scope_id}"` tuples joined by `\n`, (5) check if latest report for `(tenant_id, report_type=entra.admin_roles)` has same fingerprint → return `created=false` if match, (6) create `StoredReport` with `previous_fingerprint` from latest existing report. All-or-nothing: if either Graph call fails, throw (no partial report). See `specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md` Service 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T019 [P] [US1] Write HighPrivilegeRoleCatalog tests in `tests/Feature/EntraAdminRoles/HighPrivilegeRoleCatalogTest.php`: (1) classify Global Administrator template_id → `critical`, (2) classify all 5 other high-privilege template_ids → `high`, (3) display_name fallback for role without template_id match, (4) unknown template_id + unknown display_name → null, (5) null display_name with unknown template_id → null, (6) `isGlobalAdministrator()` returns true only for GA template_id, (7) `allTemplateIds()` returns all 6 entries, (8) display_name matching is case-insensitive.
|
||||
- [X] T020 [US1] Write EntraAdminRolesReportService tests in `tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php`: (1) new report created with correct `report_type=entra.admin_roles`, payload schema per FR-005, and fingerprint, (2) dedup on identical fingerprint — second call returns `created=false` with existing report ID, (3) changed data → new report with different fingerprint and `previous_fingerprint` chain, (4) Graph roleDefinitions failure → exception thrown, no partial report (all-or-nothing), (5) Graph roleAssignments failure → exception thrown, no partial report, (6) payload contains role_definitions, role_assignments, totals (roles_total, assignments_total, high_privilege_assignments), and high_privilege section, (7) fingerprint is deterministic regardless of Graph response ordering, (8) tenant with zero role assignments → valid report with empty assignments and totals.
|
||||
|
||||
**Checkpoint**: US1 complete — Entra admin role data is fetched from Graph, classified, fingerprinted, and persisted as stored reports with deduplication. This is the evidence pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Generate Findings for High-Privilege Assignments (Priority: P1)
|
||||
|
||||
**Goal**: Generate, upsert, auto-resolve, and re-open findings based on report data. Create aggregate "Too many Global Admins" finding when threshold exceeded. Wire everything via the scan job with OperationRun tracking and scheduling. Maps to plan Phases C + D.
|
||||
|
||||
**Independent Test**: Run the finding generator for a tenant with a Global Administrator assignment; confirm a finding with `finding_type=entra_admin_roles`, `severity=critical`, correct fingerprint and evidence exists.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [P] [US2] Create `EntraAdminRolesFindingResult` value object in `app/Services/EntraAdminRoles/EntraAdminRolesFindingResult.php` with `readonly` properties: `int $created`, `int $resolved`, `int $reopened`, `int $unchanged`, `int $alertEventsProduced`. See `specs/105-entra-admin-roles-evidence-findings/data-model.md`.
|
||||
- [X] T022 [US2] Create `EntraAdminRolesFindingGenerator` in `app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` with constructor injecting `HighPrivilegeRoleCatalog`. Method `generate(Tenant $tenant, array $reportPayload, ?OperationRun $operationRun = null): EntraAdminRolesFindingResult` must: (1) iterate `role_assignments` from payload, classify each via catalog, (2) for each high-privilege assignment: create finding via fingerprint upsert (`firstOrNew` on `[tenant_id, fingerprint]`), fingerprint = `substr(hash('sha256', "entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}"), 0, 64)`, (3) set severity: `critical` for Global Admin, `high` for others, (4) set evidence per FR-009 schema, `subject_type='role_assignment'`, `subject_external_id="{principal_id}:{role_definition_id}"`, (5) auto-resolve stale findings: query open `entra_admin_roles` findings whose fingerprint NOT in current scan → `resolve('role_assignment_removed')`, (6) re-open resolved findings when fingerprint matches current scan, (7) aggregate finding: when GA count > 5 → create "Too many Global Admins" finding with fingerprint `substr(hash('sha256', "entra_admin_role_ga_count:{tenant_id}"), 0, 64)`, severity=high, auto-resolve when ≤ 5, (8) produce alert events for new/re-opened findings with severity ≥ high. Method `getAlertEvents(): array` returns collected events. See `specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md` Service 2.
|
||||
- [X] T023 [US2] Create `ScanEntraAdminRolesJob` in `app/Jobs/ScanEntraAdminRolesJob.php` implementing `ShouldQueue`. Constructor: `int $tenantId`, `int $workspaceId`, `?int $initiatorUserId = null`. `handle()` must: (1) resolve Tenant, check for active provider connection → return early if none (no OperationRun, no error per FR-018), (2) create OperationRun via `OperationRunService::ensureRunWithIdentity()` with type `entra.admin_roles.scan`, (3) call `EntraAdminRolesReportService::generate()`, (4) call `EntraAdminRolesFindingGenerator::generate()` with report payload (regardless of whether report was new or deduped — finding generator handles auto-resolve for stale findings from removed assignments), (5) record success on OperationRun with counts from both results, (6) on Graph error → record failure with sanitized error message, re-throw for retry. See `specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md` Job section.
|
||||
- [X] T024 [US2] Register daily scan schedule in `routes/console.php`: iterate workspaces → tenants with active provider connections → dispatch `ScanEntraAdminRolesJob` per tenant. Follow existing scheduled scan patterns in the file. See plan Phase D (scheduling) and FR-016.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T025 [US2] Write EntraAdminRolesFindingGenerator tests in `tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`: (1) creates individual findings for high-privilege assignments with correct type/severity/fingerprint/evidence/source, (2) severity mapping: Global Admin → critical, others → high, (3) idempotent upsert — same data across scans → no duplicates, `times_seen` and `last_seen_at` updated, (4) auto-resolve on removed assignment (finding status → resolved, resolved_reason = `role_assignment_removed`), (5) re-open resolved finding when role re-assigned (status → new, resolved_at/resolved_reason cleared, evidence updated), (6) aggregate "Too many Global Admins" finding created when GA count > 5 with correct fingerprint/severity/evidence, (7) aggregate finding auto-resolved when GA count ≤ 5, (8) alert events produced for new/re-opened findings with severity ≥ high, (9) no alert events for unchanged or resolved findings, (10) evidence schema includes role_display_name, principal_display_name, principal_type, principal_id, role_definition_id, directory_scope_id, is_built_in, measured_at, (11) handles all principal types (user, group, servicePrincipal), (12) subject_type='role_assignment' and subject_external_id='{principal_id}:{role_definition_id}' set on every finding, (13) stale findings for assignments no longer in scan are auto-resolved (catches removed assignments), (14) auto-resolve applies to both `new` and `acknowledged` findings (acknowledged metadata preserved), (15) scoped assignments (`directory_scope_id != '/'`) do not downgrade severity and the scope is captured in evidence.
|
||||
- [X] T026 [US2] Write ScanEntraAdminRolesJob tests in `tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php`: (1) successful run creates OperationRun with type=entra.admin_roles.scan and records success with counts, (2) skips tenant without active provider connection — no OperationRun, no findings, no report, (3) Graph failure → OperationRun marked failed with sanitized error, job re-thrown for retry, (4) finding generator called even when report was deduped (handles auto-resolve), (5) OperationRun uniqueness enforced per (workspace_id, tenant_id, run_type).
|
||||
|
||||
**Checkpoint**: US1 + US2 complete — full scan pipeline operational: Graph fetch → stored report → findings → auto-resolve → re-open → aggregate. This is the core MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Entra Permissions in Permission Posture (Priority: P2)
|
||||
|
||||
**Goal**: Integrate Entra-specific Graph permissions into the existing permission posture pipeline so posture scores reflect whether tenants can run admin role scans. Maps to plan Phase D (TenantPermissionService merge).
|
||||
|
||||
**Independent Test**: Verify that after loading the merged registry (Intune + Entra), `RoleManagement.Read.Directory` appears in the required permissions list. Verify posture score computation includes it.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T027 [US4] Modify `app/Services/Intune/TenantPermissionService.php` method `getRequiredPermissions()` to merge `config('entra_permissions.permissions', [])` alongside existing `config('intune_permissions.permissions', [])`. The merge must be non-breaking (existing Intune posture flows unchanged). See `specs/105-entra-admin-roles-evidence-findings/contracts/internal-services.md` Modified: TenantPermissionService section.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T028 [US4] Extend or create tests in `tests/Feature/EntraAdminRoles/EntraPermissionsRegistryTest.php`: (1) merged required permissions list includes both Intune and Entra entries, (2) `RoleManagement.Read.Directory` appears in merged list with correct type and features, (3) existing Intune permissions unchanged after merge, (4) empty `entra_permissions.permissions` config → merge returns only Intune entries (non-breaking fallback), (5) posture score reflects the Entra permission gap (missing `RoleManagement.Read.Directory` results in score < 100).
|
||||
|
||||
**Checkpoint**: US4 complete — posture scores accurately reflect Entra permission gaps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 3 — Alert on High-Privilege Admin Role Events (Priority: P2)
|
||||
|
||||
**Goal**: Connect admin roles findings to the existing alert pipeline so operators receive notifications for new high-privilege assignments. Maps to plan Phase E.
|
||||
|
||||
**Independent Test**: Create an alert rule for `entra.admin_roles.high` with min severity = high, run the finding generator with a new Global Admin assignment, and confirm a delivery is queued.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T029 [US3] Add `entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $windowStart): array` method to `app/Jobs/Alerts/EvaluateAlertsJob.php` — queries `Finding` where `finding_type='entra_admin_roles'`, `status IN ('new')`, `severity IN ('high', 'critical')`, `updated_at > $windowStart`. Returns event arrays matching existing event schema (`event_type=entra.admin_roles.high`, `fingerprint_key=finding:{id}`). Wire the method into `handle()` event collection alongside existing `highDriftEvents()`, `compareFailedEvents()`, and `permissionMissingEvents()`. Follow same pattern as existing event methods.
|
||||
- [X] T030 [P] [US3] Add `EVENT_ENTRA_ADMIN_ROLES_HIGH` option to event type dropdown in `app/Filament/Resources/AlertRuleResource.php`: `AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)'`.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T031 [US3] Write alert integration tests in `tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php`: (1) alert rule for EVENT_ENTRA_ADMIN_ROLES_HIGH with min severity = high + new finding of severity = critical → delivery queued, (2) alert rule with min severity = critical + finding of severity = high → no delivery queued, (3) cooldown/dedupe prevents duplicate notifications for same finding across scans (fingerprint_key-based suppression), (4) resolved findings do not produce alert events, (5) new event type appears in AlertRuleResource event type options.
|
||||
|
||||
**Checkpoint**: US3 complete — operators receive alerts for high-privilege admin role events via existing alert channels.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Dashboard Widget (Priority: P3) + User Story 6 — Report Viewer (Priority: P3)
|
||||
|
||||
**Goal (US5)**: Provide a tenant dashboard card showing admin roles posture at-a-glance with "Scan now" CTA and "View latest report" link.
|
||||
**Goal (US6)**: Ensure existing stored reports viewer supports filtering by `entra.admin_roles` report type.
|
||||
Maps to plan Phase F.
|
||||
|
||||
**Independent Test (US5)**: Navigate to a tenant dashboard after a scan; confirm the card shows correct timestamp and high-privilege count.
|
||||
**Independent Test (US6)**: Navigate to stored reports viewer filtered by `entra.admin_roles`; confirm report displays summary and high-privilege assignments table.
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T032 [P] [US5] Create `admin-roles-summary.blade.php` in `resources/views/filament/widgets/tenant/admin-roles-summary.blade.php` — card template with: summary stats (last scan timestamp, high-privilege count), empty state ("No scan performed"), "Scan now" CTA (gated by `ENTRA_ROLES_MANAGE`), "View latest report" link (gated by `ENTRA_ROLES_VIEW`). Follow existing tenant card widget templates.
|
||||
- [X] T033 [US5] Create `AdminRolesSummaryWidget` in `app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php` extending `Widget`. Must: (1) resolve tenant via `Filament::getTenant()`, (2) query latest `StoredReport` where `report_type=entra.admin_roles` and `tenant_id` matches, (3) extract last scan timestamp + high-privilege count from payload, (4) implement `canView()` gated by `ENTRA_ROLES_VIEW` capability via `Gate::check()` (server-side enforcement, not just UI hiding), (5) "Scan now" action dispatches `ScanEntraAdminRolesJob` after checking `ENTRA_ROLES_MANAGE` server-side, (6) render empty state when no report exists. Follow existing tenant dashboard widget patterns.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [X] T034 [US5] Write widget tests in `tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` (Livewire component test): (1) widget renders with report data — shows timestamp and high-privilege count, (2) empty state renders "No scan performed" when no report exists, (3) "Scan now" dispatches `ScanEntraAdminRolesJob` for user with `ENTRA_ROLES_MANAGE`, (4) "Scan now" not visible / returns 403 for user with `ENTRA_ROLES_VIEW` but without `ENTRA_ROLES_MANAGE`, (5) widget hidden (canView returns false) for user without `ENTRA_ROLES_VIEW`, (6) non-member → 404.
|
||||
|
||||
### Tests for User Story 6
|
||||
|
||||
- [X] T035 [US6] Write report viewer integration test in `tests/Feature/EntraAdminRoles/AdminRolesReportViewerTest.php`: first confirm the existing stored reports viewer can render the `entra.admin_roles` payload as a summary + high-privilege assignments table; then assert: (1) stored reports viewer shows reports with `report_type=entra.admin_roles`, (2) report displays summary totals and high-privilege assignments table from payload, (3) multiple reports ordered by creation date descending.
|
||||
|
||||
**Checkpoint**: US5 + US6 complete — tenant dashboard shows admin roles posture card, and stored reports viewer supports the new report type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Code quality, full test pass, and end-to-end validation.
|
||||
|
||||
- [X] T036 Run `vendor/bin/sail bin pint --dirty` to fix formatting across all modified/created files.
|
||||
- [X] T037 Run full Spec 105 test suite: `vendor/bin/sail artisan test --compact --filter=EntraAdminRoles` and verify all tests pass.
|
||||
- [X] T038 Run quickstart.md validation: verify all files listed in quickstart.md "New Files Created" and "Modified Files Summary" tables exist and match the implementation.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
```
|
||||
Phase 2 (Foundation) ─────┬──> Phase 3 (US1 - P1) ──> Phase 4 (US2 - P1) 🎯 MVP
|
||||
│ │
|
||||
│ ├──> Phase 5 (US4 - P2)
|
||||
│ ├──> Phase 6 (US3 - P2)
|
||||
│ └──> Phase 7 (US5+US6 - P3)
|
||||
│
|
||||
└── BLOCKS all user story work
|
||||
|
||||
Phase 5, Phase 6, Phase 7 can proceed in parallel after Phase 4
|
||||
|
||||
Phase 8 (Polish) depends on all phases complete
|
||||
```
|
||||
|
||||
- **Foundation (Phase 2)**: No dependencies — start immediately. BLOCKS all user stories.
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 completion. Report service is prerequisite for finding generator.
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 (finding generator uses report payload structure; scan job calls report service). **Completing Phase 4 delivers the core MVP.**
|
||||
- **US4 (Phase 5)**: Depends on Phase 4 (posture score context requires scan to be operational). Can run in parallel with Phase 6 and Phase 7.
|
||||
- **US3 (Phase 6)**: Depends on Phase 4 (finding generator produces alert events). Can run in parallel with Phase 5 and Phase 7.
|
||||
- **US5+US6 (Phase 7)**: Depends on Phase 4 (widget displays report data from completed scans). Can run in parallel with Phase 5 and Phase 6.
|
||||
- **Polish (Phase 8)**: Depends on all user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundation — no dependencies on other stories
|
||||
- **User Story 2 (P1)**: Depends on US1 (finding generator uses catalog + report payload structure)
|
||||
- **User Story 4 (P2)**: Independent of other stories after Phase 4 — only requires Foundation config
|
||||
- **User Story 3 (P2)**: Independent after Phase 4 — only requires finding generator to produce events
|
||||
- **User Story 5 (P3)**: Independent after Phase 4 — widget queries stored reports
|
||||
- **User Story 6 (P3)**: Independent after Phase 4 — tests existing viewer with new report type
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Value objects before services
|
||||
- Services before jobs
|
||||
- Jobs before schedule registration
|
||||
- Implementation before tests
|
||||
- Core logic before integration points
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 2 (Foundation)**:
|
||||
- T002 + T003 (config files, independent)
|
||||
- T004 + T005 + T006 + T007 (model/enum constants, independent files)
|
||||
- T008 + T009 (capabilities, can be done together logically but different files)
|
||||
- T010 + T011 (badge + factory, independent files)
|
||||
- T013 + T014 + T015 (tests, independent test files)
|
||||
|
||||
**Phase 3 (US1)**:
|
||||
- T016 + T017 (HighPrivilegeRoleCatalog + ReportResult VO, independent files)
|
||||
- T019 can start after T016 (tests catalog)
|
||||
|
||||
**Phase 4 (US2)**:
|
||||
- T021 can run in parallel with T016/T017 if started early (VO, no dependencies)
|
||||
|
||||
**Phases 5 + 6 + 7** (after Phase 4 complete):
|
||||
- T027 (US4) + T029 (US3) + T032+T033 (US5) — all independent of each other
|
||||
- T030 (US3 AlertRuleResource change) can run in parallel with T029
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Foundation Phase
|
||||
|
||||
```bash
|
||||
# Batch 1: Migration + Config (parallel)
|
||||
T001: Create fingerprint migration
|
||||
T002: Create config/entra_permissions.php
|
||||
T003: Add Graph contract entries
|
||||
|
||||
# Batch 2: Constants + Capabilities + Badge + Factory (parallel, after migration written)
|
||||
T004: StoredReport constant + fillables
|
||||
T005: Finding constant
|
||||
T006: AlertRule constant
|
||||
T007: OperationRunType enum case
|
||||
T008: Capabilities constants
|
||||
T009: RoleCapabilityMap update
|
||||
T010: FindingTypeBadge mapping
|
||||
T011: FindingFactory state
|
||||
|
||||
# Batch 3: Run migration
|
||||
T012: vendor/bin/sail artisan migrate
|
||||
|
||||
# Batch 4: Foundation tests (parallel)
|
||||
T013: StoredReport fingerprint test
|
||||
T014: Badge rendering test
|
||||
T015: Capabilities registry test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 + 2)
|
||||
|
||||
1. Complete Phase 2: Foundation
|
||||
2. Complete Phase 3: User Story 1 (catalog + report service)
|
||||
3. Complete Phase 4: User Story 2 (finding generator + scan job + scheduling)
|
||||
4. **STOP and VALIDATE**: Run `vendor/bin/sail artisan test --compact --filter=EntraAdminRoles`
|
||||
5. Full scan pipeline operational: Graph → report → findings → auto-resolve → aggregate
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundation → ready
|
||||
2. US1 (P1) → evidence pipeline: reports created from Graph data ✅
|
||||
3. US2 (P1) → findings + job: full scan pipeline with findings lifecycle ✅ **MVP!**
|
||||
4. US4 (P2) → posture: Entra permissions in posture scores ✅
|
||||
5. US3 (P2) → alerts: push notifications for high-privilege events ✅
|
||||
6. US5+US6 (P3) → UI: dashboard card + report viewer ✅
|
||||
7. Polish → formatting, full test pass, quickstart validation ✅
|
||||
|
||||
Each phase adds value without breaking previous phases.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies on incomplete tasks
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Fingerprint for individual findings: `sha256("entra_admin_role:{tenant_id}:{role_template_or_id}:{principal_id}:{scope_id}")`
|
||||
- Fingerprint for aggregate finding: `sha256("entra_admin_role_ga_count:{tenant_id}")`
|
||||
- Report fingerprint: SHA-256 of sorted `"{role_template_or_id}:{principal_id}:{scope_id}"` tuples
|
||||
- Severity: `critical` for Global Administrator, `high` for all other high-privilege roles
|
||||
- Threshold for "Too many Global Admins": 5 (hardcoded v1, TODO for future settings)
|
||||
- RBAC boundary: `ENTRA_ROLES_VIEW` gates card + report only; findings use existing `FINDINGS_VIEW`
|
||||
- All Graph calls go through `GraphClientInterface` via registered contracts
|
||||
- Scan is non-destructive (read-only) — no `->requiresConfirmation()` needed
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
184
tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php
Normal file
184
tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createEntraAlertRuleWithDestination(int $workspaceId, string $minSeverity, int $cooldownSeconds = 0): array
|
||||
{
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'minimum_severity' => $minSeverity,
|
||||
'is_enabled' => true,
|
||||
'cooldown_seconds' => $cooldownSeconds,
|
||||
]);
|
||||
|
||||
$rule->destinations()->attach($destination->getKey(), [
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
return [$rule, $destination];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('queues delivery when entra admin role finding matches alert rule severity', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createEntraAlertRuleWithDestination($workspaceId, 'high');
|
||||
|
||||
$finding = Finding::factory()->entraAdminRoles()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'High-privilege Entra admin role detected',
|
||||
'body' => 'Role "Global Administrator" assigned to Alice Admin (severity: critical).',
|
||||
'metadata' => ['finding_id' => (int) $finding->getKey()],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
$created = $dispatchService->dispatchEvent($workspace, $event);
|
||||
|
||||
expect($created)->toBe(1);
|
||||
|
||||
$delivery = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('event_type', AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
|
||||
->first();
|
||||
|
||||
expect($delivery)->not->toBeNull()
|
||||
->and($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
||||
->and($delivery->severity)->toBe(Finding::SEVERITY_CRITICAL);
|
||||
});
|
||||
|
||||
it('does not queue delivery when finding severity is below minimum', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createEntraAlertRuleWithDestination($workspaceId, 'critical');
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'finding:999',
|
||||
'title' => 'High-privilege Entra admin role detected',
|
||||
'body' => 'Role "Privileged Role Administrator" assigned to Bob (severity: high).',
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
$created = $dispatchService->dispatchEvent($workspace, $event);
|
||||
|
||||
expect($created)->toBe(0);
|
||||
});
|
||||
|
||||
it('suppresses duplicate delivery via fingerprint cooldown', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createEntraAlertRuleWithDestination($workspaceId, 'high', cooldownSeconds: 3600);
|
||||
|
||||
$finding = Finding::factory()->entraAdminRoles()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'High-privilege Entra admin role detected',
|
||||
'body' => 'Role "Global Administrator" assigned to Alice Admin.',
|
||||
'metadata' => ['finding_id' => (int) $finding->getKey()],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
// First dispatch — should create a QUEUED delivery
|
||||
$firstCreated = $dispatchService->dispatchEvent($workspace, $event);
|
||||
expect($firstCreated)->toBe(1);
|
||||
|
||||
// Second dispatch — same fingerprint within cooldown → SUPPRESSED
|
||||
$secondCreated = $dispatchService->dispatchEvent($workspace, $event);
|
||||
expect($secondCreated)->toBe(1);
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('event_type', AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($deliveries)->toHaveCount(2)
|
||||
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
||||
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||
});
|
||||
|
||||
it('resolved findings are excluded from entraAdminRolesHighEvents', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
// Create a resolved finding — should not appear in events
|
||||
Finding::factory()->entraAdminRoles()->resolved()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
]);
|
||||
|
||||
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId);
|
||||
$reflection = new ReflectionMethod($job, 'entraAdminRolesHighEvents');
|
||||
|
||||
$events = $reflection->invoke(
|
||||
$job,
|
||||
$workspaceId,
|
||||
CarbonImmutable::now('UTC')->subHours(1),
|
||||
);
|
||||
|
||||
expect($events)->toBe([]);
|
||||
});
|
||||
|
||||
it('new event type appears in AlertRuleResource event type options', function (): void {
|
||||
$options = AlertRuleResource::eventTypeOptions();
|
||||
|
||||
expect($options)->toHaveKey(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
|
||||
->and($options[AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH])->toBe('Entra admin roles (high privilege)');
|
||||
});
|
||||
140
tests/Feature/EntraAdminRoles/AdminRolesReportViewerTest.php
Normal file
140
tests/Feature/EntraAdminRoles/AdminRolesReportViewerTest.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createEntraReport(Tenant $tenant, array $summaryOverrides = [], ?string $fingerprint = null): StoredReport
|
||||
{
|
||||
$totals = array_merge([
|
||||
'roles_total' => 8,
|
||||
'assignments_total' => 12,
|
||||
'high_privilege_assignments' => 5,
|
||||
], $summaryOverrides);
|
||||
|
||||
return StoredReport::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => $fingerprint ?? hash('sha256', (string) now()->timestamp.random_int(0, 99999)),
|
||||
'payload' => [
|
||||
'provider_key' => 'microsoft',
|
||||
'domain' => 'entra.admin_roles',
|
||||
'measured_at' => now()->toIso8601String(),
|
||||
'role_definitions' => [],
|
||||
'role_assignments' => [],
|
||||
'totals' => $totals,
|
||||
'high_privilege' => [
|
||||
[
|
||||
'role_display_name' => 'Global Administrator',
|
||||
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'principal_id' => 'user-ga-1',
|
||||
'principal_display_name' => 'Admin User',
|
||||
'assignment_scope' => '/',
|
||||
'severity' => 'critical',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StoredReport query by report type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns stored reports with report_type entra.admin_roles', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$report = createEntraReport($tenant);
|
||||
|
||||
// Also create a report of a different type to ensure filtering works
|
||||
StoredReport::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => 'permission_posture',
|
||||
'fingerprint' => hash('sha256', 'other-report'),
|
||||
'payload' => ['summary' => ['score' => 85]],
|
||||
]);
|
||||
|
||||
$results = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->get();
|
||||
|
||||
expect($results)
|
||||
->toHaveCount(1)
|
||||
->and((int) $results->first()->getKey())->toBe((int) $report->getKey());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report payload contains summary and high-privilege assignments table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('report payload contains summary totals and high-privilege assignments', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$report = createEntraReport($tenant, [
|
||||
'roles_total' => 10,
|
||||
'assignments_total' => 20,
|
||||
'high_privilege_assignments' => 8,
|
||||
]);
|
||||
|
||||
$report->refresh();
|
||||
$payload = $report->payload;
|
||||
|
||||
expect($payload)
|
||||
->toBeArray()
|
||||
->toHaveKey('totals')
|
||||
->toHaveKey('high_privilege');
|
||||
|
||||
$totals = $payload['totals'];
|
||||
expect($totals)
|
||||
->toHaveKey('roles_total', 10)
|
||||
->toHaveKey('assignments_total', 20)
|
||||
->toHaveKey('high_privilege_assignments', 8);
|
||||
|
||||
$assignments = $payload['high_privilege'];
|
||||
expect($assignments)
|
||||
->toBeArray()
|
||||
->toHaveCount(1);
|
||||
|
||||
expect($assignments[0])
|
||||
->toHaveKey('role_display_name', 'Global Administrator')
|
||||
->toHaveKey('severity', 'critical')
|
||||
->toHaveKey('principal_display_name', 'Admin User');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple reports ordered by creation date descending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('orders multiple reports by creation date descending', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$older = createEntraReport($tenant, ['high_privilege_assignments' => 3], 'fp-older');
|
||||
$older->forceFill(['created_at' => now()->subHours(2)])->save();
|
||||
|
||||
$newer = createEntraReport($tenant, ['high_privilege_assignments' => 7], 'fp-newer');
|
||||
$newer->forceFill(['created_at' => now()->subHour()])->save();
|
||||
|
||||
$latest = createEntraReport($tenant, ['high_privilege_assignments' => 1], 'fp-latest');
|
||||
|
||||
$results = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
expect($results)->toHaveCount(3);
|
||||
expect((int) $results[0]->getKey())->toBe((int) $latest->getKey());
|
||||
expect((int) $results[1]->getKey())->toBe((int) $newer->getKey());
|
||||
expect((int) $results[2]->getKey())->toBe((int) $older->getKey());
|
||||
});
|
||||
197
tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php
Normal file
197
tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php
Normal file
@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||
use App\Jobs\ScanEntraAdminRolesJob;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createAdminRolesReport(Tenant $tenant, ?array $summaryOverrides = null): StoredReport
|
||||
{
|
||||
$totals = array_merge([
|
||||
'roles_total' => 8,
|
||||
'assignments_total' => 12,
|
||||
'high_privilege_assignments' => 5,
|
||||
], $summaryOverrides ?? []);
|
||||
|
||||
return StoredReport::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => hash('sha256', (string) now()->timestamp),
|
||||
'payload' => [
|
||||
'provider_key' => 'microsoft',
|
||||
'domain' => 'entra.admin_roles',
|
||||
'measured_at' => now()->toIso8601String(),
|
||||
'role_definitions' => [],
|
||||
'role_assignments' => [],
|
||||
'totals' => $totals,
|
||||
'high_privilege' => [
|
||||
[
|
||||
'role_display_name' => 'Global Administrator',
|
||||
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'principal_id' => 'user-1',
|
||||
'principal_display_name' => 'Admin User',
|
||||
'assignment_scope' => '/',
|
||||
'severity' => 'critical',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget renders with report data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('renders with report data showing timestamp and high-privilege count', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
createAdminRolesReport($tenant, [
|
||||
'high_privilege_assignments' => 7,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->assertSee('Entra admin roles')
|
||||
->assertSee('High-privilege assignments')
|
||||
->assertSee('7')
|
||||
->assertSee('Last scan')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('renders empty state when no report exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->assertSee('No scan performed yet.')
|
||||
->assertDontSee('Last scan')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan now — dispatches job
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('dispatches ScanEntraAdminRolesJob when user has ENTRA_ROLES_MANAGE', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->call('scanNow')
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(ScanEntraAdminRolesJob::class, function (ScanEntraAdminRolesJob $job) use ($tenant, $user): bool {
|
||||
return $job->tenantId === (int) $tenant->getKey()
|
||||
&& $job->workspaceId === (int) $tenant->workspace_id
|
||||
&& $job->initiatorUserId === (int) $user->getKey();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan now — RBAC enforcement (readonly cannot scan)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('returns 403 when readonly user tries to scan', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->call('scanNow')
|
||||
->assertForbidden();
|
||||
|
||||
Queue::assertNotPushed(ScanEntraAdminRolesJob::class);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-member user sees empty state (no tenant data exposed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('renders empty state for non-member user', function (): void {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->assertSee('No scan performed yet.')
|
||||
->assertDontSee('Scan now')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget shows scan button only for users with manage capability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('does not show scan button for readonly users', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->assertDontSee('Scan now')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('shows scan button for owner users', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->assertSee('Scan now')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification on successful scan dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('sends success notification after scan dispatch', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
|
||||
->call('scanNow')
|
||||
->assertSuccessful();
|
||||
|
||||
$notifications = collect(session('filament.notifications', []));
|
||||
$titles = $notifications->pluck('title')->filter()->values()->all();
|
||||
|
||||
expect($titles)->toContain('Entra admin roles scan queued');
|
||||
});
|
||||
@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
|
||||
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function gaRoleDef(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'def-ga',
|
||||
'displayName' => 'Global Administrator',
|
||||
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'isBuiltIn' => true,
|
||||
];
|
||||
}
|
||||
|
||||
function secAdminRoleDef(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'def-secadmin',
|
||||
'displayName' => 'Security Administrator',
|
||||
'templateId' => '194ae4cb-b126-40b2-bd5b-6091b380977d',
|
||||
'isBuiltIn' => true,
|
||||
];
|
||||
}
|
||||
|
||||
function readerRoleDef(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'def-reader',
|
||||
'displayName' => 'Directory Readers',
|
||||
'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601',
|
||||
'isBuiltIn' => true,
|
||||
];
|
||||
}
|
||||
|
||||
function makeAssignment(string $id, string $roleDefId, string $principalId, string $odataType = '#microsoft.graph.user', string $displayName = 'User', string $scopeId = '/'): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'roleDefinitionId' => $roleDefId,
|
||||
'principalId' => $principalId,
|
||||
'directoryScopeId' => $scopeId,
|
||||
'principal' => [
|
||||
'@odata.type' => $odataType,
|
||||
'displayName' => $displayName,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function buildPayload(array $roleDefinitions, array $roleAssignments, ?string $measuredAt = null): array
|
||||
{
|
||||
return [
|
||||
'provider_key' => 'microsoft',
|
||||
'domain' => 'entra.admin_roles',
|
||||
'measured_at' => $measuredAt ?? CarbonImmutable::now('UTC')->toIso8601String(),
|
||||
'role_definitions' => $roleDefinitions,
|
||||
'role_assignments' => $roleAssignments,
|
||||
'totals' => [
|
||||
'roles_total' => count($roleDefinitions),
|
||||
'assignments_total' => count($roleAssignments),
|
||||
'high_privilege_assignments' => 0,
|
||||
],
|
||||
'high_privilege' => [],
|
||||
];
|
||||
}
|
||||
|
||||
function makeGenerator(): EntraAdminRolesFindingGenerator
|
||||
{
|
||||
return new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('creates findings for high-privilege assignments with correct attributes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef(), secAdminRoleDef()],
|
||||
[
|
||||
makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin'),
|
||||
makeAssignment('a2', 'def-secadmin', 'user-2', '#microsoft.graph.user', 'Bob SecAdmin'),
|
||||
],
|
||||
);
|
||||
|
||||
$result = makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
expect($result->created)->toBe(2)
|
||||
->and($result->resolved)->toBe(0)
|
||||
->and($result->reopened)->toBe(0)
|
||||
->and($result->unchanged)->toBe(0);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->get();
|
||||
|
||||
expect($findings)->toHaveCount(2);
|
||||
|
||||
$gaFinding = $findings->firstWhere('severity', Finding::SEVERITY_CRITICAL);
|
||||
expect($gaFinding)->not->toBeNull()
|
||||
->and($gaFinding->source)->toBe('entra.admin_roles')
|
||||
->and($gaFinding->subject_type)->toBe('role_assignment')
|
||||
->and($gaFinding->subject_external_id)->toBe('user-1:def-ga')
|
||||
->and($gaFinding->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
$secFinding = $findings->firstWhere('severity', Finding::SEVERITY_HIGH);
|
||||
expect($secFinding)->not->toBeNull()
|
||||
->and($secFinding->subject_external_id)->toBe('user-2:def-secadmin');
|
||||
});
|
||||
|
||||
it('maps severity: GA is critical, others are high', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef(), secAdminRoleDef()],
|
||||
[
|
||||
makeAssignment('a1', 'def-ga', 'user-1'),
|
||||
makeAssignment('a2', 'def-secadmin', 'user-2'),
|
||||
],
|
||||
);
|
||||
|
||||
makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->orderBy('severity')
|
||||
->get();
|
||||
|
||||
$severities = $findings->pluck('severity')->toArray();
|
||||
|
||||
expect($severities)->toContain(Finding::SEVERITY_CRITICAL)
|
||||
->and($severities)->toContain(Finding::SEVERITY_HIGH);
|
||||
});
|
||||
|
||||
it('is idempotent — same data produces no duplicates', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
|
||||
$generator = makeGenerator();
|
||||
$first = $generator->generate($tenant, $payload);
|
||||
$second = $generator->generate($tenant, $payload);
|
||||
|
||||
expect($first->created)->toBe(1)
|
||||
->and($second->created)->toBe(0)
|
||||
->and($second->unchanged)->toBe(1);
|
||||
|
||||
$count = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(1);
|
||||
});
|
||||
|
||||
it('auto-resolves when assignment is removed', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
// First scan: user-1 has GA
|
||||
$payload1 = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
$generator->generate($tenant, $payload1);
|
||||
|
||||
// Second scan: user-1 GA removed
|
||||
$payload2 = buildPayload([gaRoleDef()], []);
|
||||
$result2 = $generator->generate($tenant, $payload2);
|
||||
|
||||
expect($result2->resolved)->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->where('subject_external_id', 'user-1:def-ga')
|
||||
->first();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_reason)->toBe('role_assignment_removed');
|
||||
});
|
||||
|
||||
it('re-opens resolved finding when role is re-assigned', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
// Scan 1: create
|
||||
$payload1 = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice')],
|
||||
);
|
||||
$generator->generate($tenant, $payload1);
|
||||
|
||||
// Scan 2: remove → auto-resolve
|
||||
$payload2 = buildPayload([gaRoleDef()], []);
|
||||
$generator->generate($tenant, $payload2);
|
||||
|
||||
// Scan 3: re-assign → re-open
|
||||
$payload3 = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Reactivated')],
|
||||
);
|
||||
$result3 = $generator->generate($tenant, $payload3);
|
||||
|
||||
expect($result3->reopened)->toBe(1);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->where('subject_external_id', 'user-1:def-ga')
|
||||
->first();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_NEW)
|
||||
->and($finding->resolved_at)->toBeNull()
|
||||
->and($finding->resolved_reason)->toBeNull()
|
||||
->and($finding->evidence_jsonb['principal_display_name'])->toBe('Alice Reactivated');
|
||||
});
|
||||
|
||||
it('creates aggregate finding when GA count exceeds threshold', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$assignments = [];
|
||||
|
||||
for ($i = 1; $i <= 6; $i++) {
|
||||
$assignments[] = makeAssignment("a{$i}", 'def-ga', "user-{$i}", '#microsoft.graph.user', "GA User {$i}");
|
||||
}
|
||||
|
||||
$payload = buildPayload([gaRoleDef()], $assignments);
|
||||
|
||||
$result = makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
// 6 individual + 1 aggregate = 7 findings
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->get();
|
||||
|
||||
expect($findings)->toHaveCount(7);
|
||||
|
||||
$aggregate = $findings->firstWhere('subject_external_id', 'ga_aggregate');
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate->severity)->toBe(Finding::SEVERITY_HIGH)
|
||||
->and($aggregate->evidence_jsonb['count'])->toBe(6)
|
||||
->and($aggregate->evidence_jsonb['threshold'])->toBe(5)
|
||||
->and($aggregate->evidence_jsonb['principals'])->toHaveCount(6);
|
||||
});
|
||||
|
||||
it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
// Scan 1: 6 GAs → aggregate created
|
||||
$assignments = [];
|
||||
|
||||
for ($i = 1; $i <= 6; $i++) {
|
||||
$assignments[] = makeAssignment("a{$i}", 'def-ga', "user-{$i}");
|
||||
}
|
||||
|
||||
$generator->generate($tenant, buildPayload([gaRoleDef()], $assignments));
|
||||
|
||||
// Scan 2: 5 GAs → aggregate auto-resolved
|
||||
$smallerAssignments = array_slice($assignments, 0, 5);
|
||||
$result2 = $generator->generate($tenant, buildPayload([gaRoleDef()], $smallerAssignments));
|
||||
|
||||
expect($result2->resolved)->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$aggregate = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('subject_external_id', 'ga_aggregate')
|
||||
->first();
|
||||
|
||||
expect($aggregate->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($aggregate->resolved_reason)->toBe('ga_count_within_threshold');
|
||||
});
|
||||
|
||||
it('produces alert events for new and re-opened findings with severity >= high', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
|
||||
$generator->generate($tenant, $payload);
|
||||
|
||||
$events = $generator->getAlertEvents();
|
||||
expect($events)->not->toBeEmpty();
|
||||
|
||||
$event = $events[0];
|
||||
expect($event['event_type'])->toBe(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH)
|
||||
->and($event['tenant_id'])->toBe((int) $tenant->getKey());
|
||||
});
|
||||
|
||||
it('produces no alert events for unchanged or resolved findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
|
||||
// Scan 1: alert events produced for new finding
|
||||
$generator->generate($tenant, $payload);
|
||||
|
||||
// Scan 2: unchanged — no new alert events
|
||||
$generator2 = makeGenerator();
|
||||
$generator2->generate($tenant, $payload);
|
||||
$events = $generator2->getAlertEvents();
|
||||
|
||||
expect($events)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('evidence contains all required fields', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin')],
|
||||
);
|
||||
|
||||
makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->where('subject_external_id', 'user-1:def-ga')
|
||||
->first();
|
||||
|
||||
$evidence = $finding->evidence_jsonb;
|
||||
|
||||
expect($evidence)->toHaveKeys([
|
||||
'role_display_name',
|
||||
'principal_display_name',
|
||||
'principal_type',
|
||||
'principal_id',
|
||||
'role_definition_id',
|
||||
'role_template_id',
|
||||
'directory_scope_id',
|
||||
'is_built_in',
|
||||
'measured_at',
|
||||
])
|
||||
->and($evidence['role_display_name'])->toBe('Global Administrator')
|
||||
->and($evidence['principal_display_name'])->toBe('Alice Admin')
|
||||
->and($evidence['principal_type'])->toBe('user')
|
||||
->and($evidence['principal_id'])->toBe('user-1')
|
||||
->and($evidence['role_definition_id'])->toBe('def-ga')
|
||||
->and($evidence['is_built_in'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles all principal types correctly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[
|
||||
makeAssignment('a1', 'def-ga', 'p-user', '#microsoft.graph.user', 'User'),
|
||||
makeAssignment('a2', 'def-ga', 'p-group', '#microsoft.graph.group', 'Group'),
|
||||
makeAssignment('a3', 'def-ga', 'p-sp', '#microsoft.graph.servicePrincipal', 'SP'),
|
||||
],
|
||||
);
|
||||
|
||||
makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->get();
|
||||
|
||||
$types = $findings->pluck('evidence_jsonb.principal_type')->sort()->values()->toArray();
|
||||
|
||||
expect($types)->toContain('user')
|
||||
->toContain('group')
|
||||
->toContain('servicePrincipal');
|
||||
});
|
||||
|
||||
it('subject_type and subject_external_id set on every finding', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
|
||||
makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->first();
|
||||
|
||||
expect($finding->subject_type)->toBe('role_assignment')
|
||||
->and($finding->subject_external_id)->toBe('user-1:def-ga');
|
||||
});
|
||||
|
||||
it('auto-resolve applies to acknowledged findings too', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = makeGenerator();
|
||||
|
||||
// Scan 1: create
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1')],
|
||||
);
|
||||
$generator->generate($tenant, $payload);
|
||||
|
||||
// Acknowledge the finding
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('subject_external_id', 'user-1:def-ga')
|
||||
->first();
|
||||
$finding->acknowledge($user);
|
||||
|
||||
expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
|
||||
// Scan 2: remove → should auto-resolve even though acknowledged
|
||||
$payload2 = buildPayload([gaRoleDef()], []);
|
||||
$result = $generator->generate($tenant, $payload2);
|
||||
|
||||
expect($result->resolved)->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$resolved = $finding->fresh();
|
||||
expect($resolved->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($resolved->resolved_reason)->toBe('role_assignment_removed');
|
||||
});
|
||||
|
||||
it('scoped assignments do not downgrade severity', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[gaRoleDef()],
|
||||
[makeAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice', '/administrativeUnits/au-123')],
|
||||
);
|
||||
|
||||
makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->first();
|
||||
|
||||
expect($finding->severity)->toBe(Finding::SEVERITY_CRITICAL)
|
||||
->and($finding->evidence_jsonb['directory_scope_id'])->toBe('/administrativeUnits/au-123');
|
||||
});
|
||||
|
||||
it('does not create findings for non-high-privilege roles', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$payload = buildPayload(
|
||||
[readerRoleDef()],
|
||||
[makeAssignment('a1', 'def-reader', 'user-1')],
|
||||
);
|
||||
|
||||
$result = makeGenerator()->generate($tenant, $payload);
|
||||
|
||||
expect($result->created)->toBe(0);
|
||||
|
||||
$count = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
|
||||
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sampleRoleDefinitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'def-ga-001',
|
||||
'displayName' => 'Global Administrator',
|
||||
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'isBuiltIn' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'def-ua-002',
|
||||
'displayName' => 'User Administrator',
|
||||
'templateId' => 'fe930be7-5e62-47db-91af-98c3a49a38b1',
|
||||
'isBuiltIn' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'def-reader-003',
|
||||
'displayName' => 'Directory Readers',
|
||||
'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601',
|
||||
'isBuiltIn' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function sampleRoleAssignments(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'assign-1',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'user-aaa',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => [
|
||||
'@odata.type' => '#microsoft.graph.user',
|
||||
'displayName' => 'Alice Admin',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assign-2',
|
||||
'roleDefinitionId' => 'def-ua-002',
|
||||
'principalId' => 'user-bbb',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => [
|
||||
'@odata.type' => '#microsoft.graph.user',
|
||||
'displayName' => 'Bob Useradmin',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assign-3',
|
||||
'roleDefinitionId' => 'def-reader-003',
|
||||
'principalId' => 'group-ccc',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => [
|
||||
'@odata.type' => '#microsoft.graph.group',
|
||||
'displayName' => 'Readers Group',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock GraphClientInterface that returns specific data for entra types.
|
||||
*/
|
||||
function buildGraphMock(
|
||||
array $roleDefinitions,
|
||||
array $roleAssignments,
|
||||
bool $failDefinitions = false,
|
||||
bool $failAssignments = false,
|
||||
): GraphClientInterface {
|
||||
return new class($roleDefinitions, $roleAssignments, $failDefinitions, $failAssignments) implements GraphClientInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $roleDefinitions,
|
||||
private readonly array $roleAssignments,
|
||||
private readonly bool $failDefinitions,
|
||||
private readonly bool $failAssignments,
|
||||
) {}
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return match ($policyType) {
|
||||
'entraRoleDefinitions' => new GraphResponse(
|
||||
success: ! $this->failDefinitions,
|
||||
data: $this->failDefinitions ? [] : $this->roleDefinitions,
|
||||
status: $this->failDefinitions ? 403 : 200,
|
||||
errors: $this->failDefinitions ? ['Forbidden'] : [],
|
||||
),
|
||||
'entraRoleAssignments' => new GraphResponse(
|
||||
success: ! $this->failAssignments,
|
||||
data: $this->failAssignments ? [] : $this->roleAssignments,
|
||||
status: $this->failAssignments ? 403 : 200,
|
||||
errors: $this->failAssignments ? ['Forbidden'] : [],
|
||||
),
|
||||
default => new GraphResponse(success: false, status: 404, errors: ['Unknown type']),
|
||||
};
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildReportService(GraphClientInterface $graphMock): EntraAdminRolesReportService
|
||||
{
|
||||
return new EntraAdminRolesReportService(
|
||||
graphClient: $graphMock,
|
||||
catalog: new HighPrivilegeRoleCatalog,
|
||||
graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('creates a new report with correct attributes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
|
||||
|
||||
$result = $service->generate($tenant);
|
||||
|
||||
expect($result->created)->toBeTrue()
|
||||
->and($result->storedReportId)->toBeInt()
|
||||
->and($result->fingerprint)->toBeString()->toHaveLength(64)
|
||||
->and($result->payload)->toBeArray();
|
||||
|
||||
$report = StoredReport::find($result->storedReportId);
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->and($report->tenant_id)->toBe((int) $tenant->getKey())
|
||||
->and($report->fingerprint)->toBe($result->fingerprint)
|
||||
->and($report->previous_fingerprint)->toBeNull();
|
||||
});
|
||||
|
||||
it('deduplicates when fingerprint matches latest report', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
|
||||
|
||||
$first = $service->generate($tenant);
|
||||
expect($first->created)->toBeTrue();
|
||||
|
||||
$second = $service->generate($tenant);
|
||||
expect($second->created)->toBeFalse()
|
||||
->and($second->storedReportId)->toBe($first->storedReportId)
|
||||
->and($second->fingerprint)->toBe($first->fingerprint);
|
||||
|
||||
$count = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(1);
|
||||
});
|
||||
|
||||
it('chains previous_fingerprint when data changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$originalAssignments = sampleRoleAssignments();
|
||||
$service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $originalAssignments));
|
||||
$first = $service1->generate($tenant);
|
||||
|
||||
// Add a new assignment to produce a different fingerprint
|
||||
$changedAssignments = array_merge($originalAssignments, [[
|
||||
'id' => 'assign-new',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'user-zzz',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => [
|
||||
'@odata.type' => '#microsoft.graph.user',
|
||||
'displayName' => 'Zach New',
|
||||
],
|
||||
]]);
|
||||
|
||||
$service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $changedAssignments));
|
||||
$second = $service2->generate($tenant);
|
||||
|
||||
expect($second->created)->toBeTrue()
|
||||
->and($second->fingerprint)->not->toBe($first->fingerprint)
|
||||
->and(StoredReport::find($second->storedReportId)->previous_fingerprint)->toBe($first->fingerprint);
|
||||
});
|
||||
|
||||
it('throws when role definitions fetch fails', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock([], [], failDefinitions: true));
|
||||
|
||||
$service->generate($tenant);
|
||||
})->throws(RuntimeException::class, 'Failed to fetch Entra role definitions');
|
||||
|
||||
it('throws when role assignments fetch fails', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), [], failAssignments: true));
|
||||
|
||||
$service->generate($tenant);
|
||||
})->throws(RuntimeException::class, 'Failed to fetch Entra role assignments');
|
||||
|
||||
it('produces expected payload structure', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments()));
|
||||
$result = $service->generate($tenant);
|
||||
|
||||
$payload = $result->payload;
|
||||
|
||||
expect($payload)
|
||||
->toHaveKeys(['provider_key', 'domain', 'measured_at', 'role_definitions', 'role_assignments', 'totals', 'high_privilege'])
|
||||
->and($payload['provider_key'])->toBe('microsoft')
|
||||
->and($payload['domain'])->toBe('entra.admin_roles')
|
||||
->and($payload['role_definitions'])->toHaveCount(3)
|
||||
->and($payload['role_assignments'])->toHaveCount(3)
|
||||
->and($payload['totals']['roles_total'])->toBe(3)
|
||||
->and($payload['totals']['assignments_total'])->toBe(3)
|
||||
// Only GA is in the high-privilege catalog
|
||||
->and($payload['totals']['high_privilege_assignments'])->toBe(1)
|
||||
->and($payload['high_privilege'])->toHaveCount(1);
|
||||
|
||||
// Verify first high-privilege entry shape
|
||||
$ga = collect($payload['high_privilege'])->firstWhere('role_template_id', '62e90394-69f5-4237-9190-012177145e10');
|
||||
|
||||
expect($ga)->not->toBeNull()
|
||||
->and($ga['role_display_name'])->toBe('Global Administrator')
|
||||
->and($ga['principal_id'])->toBe('user-aaa')
|
||||
->and($ga['principal_type'])->toBe('user')
|
||||
->and($ga['severity'])->toBe('critical');
|
||||
});
|
||||
|
||||
it('computes deterministic fingerprint regardless of assignment order', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$assignments = sampleRoleAssignments();
|
||||
$reversedAssignments = array_reverse($assignments);
|
||||
|
||||
$service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments));
|
||||
$result1 = $service1->generate($tenant);
|
||||
|
||||
// Delete the report to allow re-creation
|
||||
StoredReport::query()->where('id', $result1->storedReportId)->delete();
|
||||
|
||||
$service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $reversedAssignments));
|
||||
$result2 = $service2->generate($tenant);
|
||||
|
||||
expect($result2->fingerprint)->toBe($result1->fingerprint);
|
||||
});
|
||||
|
||||
it('handles zero assignments gracefully', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), []));
|
||||
$result = $service->generate($tenant);
|
||||
|
||||
expect($result->created)->toBeTrue()
|
||||
->and($result->payload['totals']['assignments_total'])->toBe(0)
|
||||
->and($result->payload['totals']['high_privilege_assignments'])->toBe(0)
|
||||
->and($result->payload['high_privilege'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('resolves principal types correctly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$assignments = [
|
||||
[
|
||||
'id' => 'a1',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'p-user',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => ['@odata.type' => '#microsoft.graph.user', 'displayName' => 'User'],
|
||||
],
|
||||
[
|
||||
'id' => 'a2',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'p-group',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => ['@odata.type' => '#microsoft.graph.group', 'displayName' => 'Group'],
|
||||
],
|
||||
[
|
||||
'id' => 'a3',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'p-sp',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => ['@odata.type' => '#microsoft.graph.servicePrincipal', 'displayName' => 'SP'],
|
||||
],
|
||||
[
|
||||
'id' => 'a4',
|
||||
'roleDefinitionId' => 'def-ga-001',
|
||||
'principalId' => 'p-unknown',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => ['displayName' => 'NoType'],
|
||||
],
|
||||
];
|
||||
|
||||
$service = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments));
|
||||
$result = $service->generate($tenant);
|
||||
|
||||
$types = collect($result->payload['high_privilege'])->pluck('principal_type')->toArray();
|
||||
|
||||
expect($types)->toBe(['user', 'group', 'servicePrincipal', 'unknown']);
|
||||
});
|
||||
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\TenantRole;
|
||||
|
||||
it('has ENTRA_ROLES_VIEW and ENTRA_ROLES_MANAGE capability constants', function (): void {
|
||||
expect(Capabilities::ENTRA_ROLES_VIEW)->toBe('entra_roles.view')
|
||||
->and(Capabilities::ENTRA_ROLES_MANAGE)->toBe('entra_roles.manage');
|
||||
});
|
||||
|
||||
it('includes ENTRA_ROLES_VIEW in all() capability registry', function (): void {
|
||||
$all = Capabilities::all();
|
||||
|
||||
expect($all)->toContain(Capabilities::ENTRA_ROLES_VIEW)
|
||||
->and($all)->toContain(Capabilities::ENTRA_ROLES_MANAGE);
|
||||
});
|
||||
|
||||
it('maps ENTRA_ROLES_VIEW to Readonly and Operator roles', function (): void {
|
||||
expect(RoleCapabilityMap::hasCapability(TenantRole::Readonly, Capabilities::ENTRA_ROLES_VIEW))->toBeTrue()
|
||||
->and(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::ENTRA_ROLES_VIEW))->toBeTrue();
|
||||
});
|
||||
|
||||
it('maps ENTRA_ROLES_MANAGE to Manager and Owner roles only', function (): void {
|
||||
expect(RoleCapabilityMap::hasCapability(TenantRole::Owner, Capabilities::ENTRA_ROLES_MANAGE))->toBeTrue()
|
||||
->and(RoleCapabilityMap::hasCapability(TenantRole::Manager, Capabilities::ENTRA_ROLES_MANAGE))->toBeTrue()
|
||||
->and(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::ENTRA_ROLES_MANAGE))->toBeFalse()
|
||||
->and(RoleCapabilityMap::hasCapability(TenantRole::Readonly, Capabilities::ENTRA_ROLES_MANAGE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('maps ENTRA_ROLES_VIEW to Manager and Owner roles', function (): void {
|
||||
expect(RoleCapabilityMap::hasCapability(TenantRole::Owner, Capabilities::ENTRA_ROLES_VIEW))->toBeTrue()
|
||||
->and(RoleCapabilityMap::hasCapability(TenantRole::Manager, Capabilities::ENTRA_ROLES_VIEW))->toBeTrue();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T028 — TenantPermissionService merge tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('merged required permissions include both Intune and Entra entries', function (): void {
|
||||
$service = app(TenantPermissionService::class);
|
||||
$permissions = $service->getRequiredPermissions();
|
||||
|
||||
$keys = array_column($permissions, 'key');
|
||||
|
||||
// At least one Intune permission
|
||||
expect($keys)->toContain('DeviceManagementConfiguration.ReadWrite.All')
|
||||
// And the Entra permission
|
||||
->and($keys)->toContain('RoleManagement.Read.Directory');
|
||||
});
|
||||
|
||||
it('RoleManagement.Read.Directory has correct type and features in merged list', function (): void {
|
||||
$service = app(TenantPermissionService::class);
|
||||
$permissions = $service->getRequiredPermissions();
|
||||
|
||||
$entraPermission = collect($permissions)->firstWhere('key', 'RoleManagement.Read.Directory');
|
||||
|
||||
expect($entraPermission)->not->toBeNull()
|
||||
->and($entraPermission['type'])->toBe('application')
|
||||
->and($entraPermission['features'])->toContain('entra-admin-roles');
|
||||
});
|
||||
|
||||
it('existing Intune permissions unchanged after Entra merge', function (): void {
|
||||
$service = app(TenantPermissionService::class);
|
||||
$merged = $service->getRequiredPermissions();
|
||||
|
||||
$intuneOnly = config('intune_permissions.permissions', []);
|
||||
$intuneKeys = array_column($intuneOnly, 'key');
|
||||
|
||||
foreach ($intuneKeys as $key) {
|
||||
$original = collect($intuneOnly)->firstWhere('key', $key);
|
||||
$inMerged = collect($merged)->firstWhere('key', $key);
|
||||
|
||||
expect($inMerged)->not->toBeNull()
|
||||
->and($inMerged['key'])->toBe($original['key'])
|
||||
->and($inMerged['type'])->toBe($original['type']);
|
||||
}
|
||||
});
|
||||
|
||||
it('empty entra_permissions config returns only Intune entries', function (): void {
|
||||
config()->set('entra_permissions.permissions', []);
|
||||
|
||||
$service = app(TenantPermissionService::class);
|
||||
$permissions = $service->getRequiredPermissions();
|
||||
|
||||
$intuneOnly = config('intune_permissions.permissions', []);
|
||||
|
||||
expect($permissions)->toHaveCount(count($intuneOnly));
|
||||
|
||||
$keys = array_column($permissions, 'key');
|
||||
expect($keys)->not->toContain('RoleManagement.Read.Directory');
|
||||
});
|
||||
31
tests/Feature/EntraAdminRoles/FindingTypeBadgeTest.php
Normal file
31
tests/Feature/EntraAdminRoles/FindingTypeBadgeTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Support\Badges\Domains\FindingTypeBadge;
|
||||
|
||||
it('renders entra_admin_roles badge with danger color and identification icon', function (): void {
|
||||
$badge = new FindingTypeBadge;
|
||||
$spec = $badge->spec(Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES);
|
||||
|
||||
expect($spec->label)->toBe('Entra admin roles')
|
||||
->and($spec->color)->toBe('danger')
|
||||
->and($spec->icon)->toBe('heroicon-m-identification');
|
||||
});
|
||||
|
||||
it('still renders drift badge correctly after adding entra_admin_roles', function (): void {
|
||||
$badge = new FindingTypeBadge;
|
||||
$spec = $badge->spec(Finding::FINDING_TYPE_DRIFT);
|
||||
|
||||
expect($spec->label)->toBe('Drift')
|
||||
->and($spec->color)->toBe('info');
|
||||
});
|
||||
|
||||
it('still renders permission_posture badge correctly after adding entra_admin_roles', function (): void {
|
||||
$badge = new FindingTypeBadge;
|
||||
$spec = $badge->spec(Finding::FINDING_TYPE_PERMISSION_POSTURE);
|
||||
|
||||
expect($spec->label)->toBe('Permission posture')
|
||||
->and($spec->color)->toBe('warning');
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
|
||||
|
||||
it('classifies Global Administrator template_id as critical', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify('62e90394-69f5-4237-9190-012177145e10', null))->toBe('critical');
|
||||
});
|
||||
|
||||
it('classifies all 5 other high-privilege template_ids as high', function (string $templateId): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify($templateId, null))->toBe('high');
|
||||
})->with([
|
||||
'Privileged Role Administrator' => 'e8611ab8-c189-46e8-94e1-60213ab1f814',
|
||||
'Security Administrator' => '194ae4cb-b126-40b2-bd5b-6091b380977d',
|
||||
'Conditional Access Administrator' => 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9',
|
||||
'Exchange Administrator' => '29232cdf-9323-42fd-ade2-1d097af3e4de',
|
||||
'Authentication Administrator' => 'c4e39bd9-1100-46d3-8c65-fb160da0071f',
|
||||
]);
|
||||
|
||||
it('falls back to display_name when template_id does not match', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify('unknown-template-id', 'Security Administrator'))->toBe('high');
|
||||
});
|
||||
|
||||
it('returns null for unknown template_id and unknown display_name', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify('unknown-template-id', 'Custom Role'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown template_id with null display_name', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify('unknown-template-id', null))->toBeNull();
|
||||
});
|
||||
|
||||
it('identifies Global Administrator via isGlobalAdministrator', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->isGlobalAdministrator('62e90394-69f5-4237-9190-012177145e10', null))->toBeTrue()
|
||||
->and($catalog->isGlobalAdministrator('e8611ab8-c189-46e8-94e1-60213ab1f814', null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns all 6 entries from allTemplateIds', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
$all = $catalog->allTemplateIds();
|
||||
|
||||
expect($all)->toHaveCount(6)
|
||||
->and($all)->toHaveKey('62e90394-69f5-4237-9190-012177145e10');
|
||||
});
|
||||
|
||||
it('matches display_name case-insensitively', function (): void {
|
||||
$catalog = new HighPrivilegeRoleCatalog;
|
||||
|
||||
expect($catalog->classify('unknown-id', 'GLOBAL ADMINISTRATOR'))->toBe('critical')
|
||||
->and($catalog->classify('unknown-id', 'global administrator'))->toBe('critical')
|
||||
->and($catalog->classify('unknown-id', 'Global Administrator'))->toBe('critical');
|
||||
});
|
||||
288
tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php
Normal file
288
tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php
Normal file
@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ScanEntraAdminRolesJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator;
|
||||
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
|
||||
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRolesReportService
|
||||
{
|
||||
return new EntraAdminRolesReportService(
|
||||
$graphClient,
|
||||
new HighPrivilegeRoleCatalog,
|
||||
app(MicrosoftGraphOptionsResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
function scanJobRoleDefs(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'def-ga',
|
||||
'displayName' => 'Global Administrator',
|
||||
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
'isBuiltIn' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function scanJobAssignments(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'assign-1',
|
||||
'roleDefinitionId' => 'def-ga',
|
||||
'principalId' => 'user-aaa',
|
||||
'directoryScopeId' => '/',
|
||||
'principal' => [
|
||||
'@odata.type' => '#microsoft.graph.user',
|
||||
'displayName' => 'Alice Admin',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function scanJobGraphMock(bool $failDefinitions = false, bool $failAssignments = false): GraphClientInterface
|
||||
{
|
||||
return new class(scanJobRoleDefs(), scanJobAssignments(), $failDefinitions, $failAssignments) implements GraphClientInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $roleDefs,
|
||||
private readonly array $assignments,
|
||||
private readonly bool $failDefs,
|
||||
private readonly bool $failAssigns,
|
||||
) {}
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return match ($policyType) {
|
||||
'entraRoleDefinitions' => new GraphResponse(
|
||||
success: ! $this->failDefs,
|
||||
data: $this->failDefs ? [] : $this->roleDefs,
|
||||
status: $this->failDefs ? 403 : 200,
|
||||
errors: $this->failDefs ? ['Forbidden'] : [],
|
||||
),
|
||||
'entraRoleAssignments' => new GraphResponse(
|
||||
success: ! $this->failAssigns,
|
||||
data: $this->failAssigns ? [] : $this->assignments,
|
||||
status: $this->failAssigns ? 403 : 200,
|
||||
errors: $this->failAssigns ? ['Forbidden'] : [],
|
||||
),
|
||||
default => new GraphResponse(success: false, status: 404),
|
||||
};
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: false, status: 501);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('successful run creates OperationRun and records success with counts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$mock = scanJobGraphMock();
|
||||
|
||||
$job = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
buildScanReportService($mock),
|
||||
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($run->summary_counts)->toBeArray()
|
||||
->and($run->summary_counts['report_created'])->toBe(1)
|
||||
->and($run->summary_counts['findings_created'])->toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify report and findings also exist
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->first();
|
||||
|
||||
expect($report)->not->toBeNull();
|
||||
|
||||
$findingsCount = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
|
||||
expect($findingsCount)->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('skips tenant without active provider connection', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
// Deliberately NOT creating a provider connection
|
||||
|
||||
$job = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
buildScanReportService(scanJobGraphMock()),
|
||||
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
$runCount = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->count();
|
||||
|
||||
expect($runCount)->toBe(0);
|
||||
|
||||
$reportCount = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
|
||||
expect($reportCount)->toBe(0);
|
||||
});
|
||||
|
||||
it('Graph failure marks OperationRun as failed and re-throws', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$mock = scanJobGraphMock(failDefinitions: true);
|
||||
|
||||
$job = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
|
||||
$thrown = false;
|
||||
|
||||
try {
|
||||
$job->handle(
|
||||
buildScanReportService($mock),
|
||||
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
} catch (RuntimeException) {
|
||||
$thrown = true;
|
||||
}
|
||||
|
||||
expect($thrown)->toBeTrue();
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
||||
->and($run->failure_summary)->toBeArray()
|
||||
->and($run->failure_summary[0]['code'])->toBe('entra.admin_roles.scan.failed');
|
||||
});
|
||||
|
||||
it('finding generator runs even when report is deduped', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$mock = scanJobGraphMock();
|
||||
|
||||
$reportService = buildScanReportService($mock);
|
||||
$findingGenerator = new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog);
|
||||
$runService = app(\App\Services\OperationRunService::class);
|
||||
|
||||
// Run 1: creates report
|
||||
$job1 = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
$job1->handle($reportService, $findingGenerator, $runService);
|
||||
|
||||
$reportCount = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
expect($reportCount)->toBe(1);
|
||||
|
||||
// Mark existing OperationRun as completed so a new one can be created
|
||||
OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->update(['status' => OperationRunStatus::Completed->value]);
|
||||
|
||||
// Run 2: report deduped, but findings still processed
|
||||
$job2 = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
$job2->handle($reportService, $findingGenerator, $runService);
|
||||
|
||||
// Still only 1 report (deduped)
|
||||
$reportCount2 = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->count();
|
||||
expect($reportCount2)->toBe(1);
|
||||
|
||||
// But the run completed successfully with deduped count
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
expect($latestRun)->not->toBeNull()
|
||||
->and($latestRun->summary_counts['report_deduped'])->toBe(1);
|
||||
});
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('persists fingerprint and previous_fingerprint columns', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$report = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => str_repeat('a', 64),
|
||||
'previous_fingerprint' => str_repeat('b', 64),
|
||||
]);
|
||||
|
||||
$fresh = StoredReport::query()->find($report->getKey());
|
||||
|
||||
expect($fresh->fingerprint)->toBe(str_repeat('a', 64))
|
||||
->and($fresh->previous_fingerprint)->toBe(str_repeat('b', 64));
|
||||
});
|
||||
|
||||
it('prevents duplicate (tenant_id, report_type, fingerprint) via unique index', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$fingerprint = hash('sha256', 'test-content');
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
expect(fn () => StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => $fingerprint,
|
||||
]))->toThrow(\Illuminate\Database\QueryException::class);
|
||||
});
|
||||
|
||||
it('allows null fingerprint for existing reports without fingerprinting', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$report = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'fingerprint' => null,
|
||||
'previous_fingerprint' => null,
|
||||
]);
|
||||
|
||||
$fresh = StoredReport::query()->find($report->getKey());
|
||||
|
||||
expect($fresh->fingerprint)->toBeNull()
|
||||
->and($fresh->previous_fingerprint)->toBeNull();
|
||||
});
|
||||
@ -2,13 +2,17 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
|
||||
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('Keine Daten verfügbar')
|
||||
->assertSee('/admin/onboarding', false)
|
||||
->assertSee($expectedUrl, false)
|
||||
->assertSee('Start verification');
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
'features' => ['backup', 'restore'],
|
||||
],
|
||||
]);
|
||||
config()->set('entra_permissions.permissions', []);
|
||||
|
||||
TenantPermission::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
|
||||
@ -2,14 +2,18 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
|
||||
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('Re-run verification')
|
||||
->assertSee('/admin/onboarding', false)
|
||||
->assertSee($expectedUrl, false)
|
||||
->assertDontSee('/admin/t/', false);
|
||||
});
|
||||
|
||||
@ -20,6 +24,6 @@
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
|
||||
->assertSee('<details data-testid="technical-details"', false)
|
||||
->assertSee('data-testid="technical-details"', false)
|
||||
->assertDontSee('data-testid="technical-details" open', false);
|
||||
});
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| T001 — Sidebar context tests for Required Permissions page
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Verifies that the Required Permissions page (a workspace-scoped page
|
||||
| that uses {tenant} for data display only) always renders the workspace
|
||||
| sidebar — never the tenant sidebar. Also verifies FR-002: authorization
|
||||
| (404 for non-members) still works after the middleware change.
|
||||
|
|
||||
*/
|
||||
|
||||
it('renders workspace navigation items on the required permissions page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Workspace nav items from configureNavigationForRequest() workspace builder
|
||||
$response->assertSee('Operations', false);
|
||||
$response->assertSee('Audit Log', false);
|
||||
});
|
||||
|
||||
it('does not render tenant navigation items on the required permissions page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Tenant-scoped nav groups/items must NOT appear
|
||||
$response->assertDontSee('>Inventory</span>', false);
|
||||
$response->assertDontSee('>Backups & Restore</span>', false);
|
||||
});
|
||||
|
||||
it('shows workspace sidebar when navigating directly via URL', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Workspace nav present (Operations is always visible in workspace nav)
|
||||
$response->assertSee('Operations', false);
|
||||
$response->assertSee('Audit Log', false);
|
||||
|
||||
// Tenant nav absent
|
||||
$response->assertDontSee('>Directory</span>', false);
|
||||
$response->assertDontSee('>Governance</span>', false);
|
||||
});
|
||||
|
||||
it('returns 404 for non-workspace-members after middleware change (FR-002 regression guard)', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
// User is NOT a workspace member — no WorkspaceMembership created
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
])
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for workspace members without tenant entitlement after middleware change (FR-002 regression guard)', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
// User IS a workspace member but NOT entitled to this tenant
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
])
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| T002 — Regression: tenant-scoped pages still show tenant sidebar
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Verifies that the middleware change does NOT affect tenant-scoped pages.
|
||||
| Pages under /admin/t/{tenant}/ must continue to show tenant sidebar.
|
||||
|
|
||||
*/
|
||||
|
||||
it('still renders tenant sidebar on tenant-scoped pages (regression guard)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
// Use the tenant dashboard — a known tenant-scoped URL
|
||||
$response = $this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Tenant-scoped nav groups MUST be present on tenant pages (Inventory group)
|
||||
$response->assertSee('Inventory', false);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| T007 — Context bar: tenant name is visible on the page
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The page's $scopedTenant is resolved from the route param via
|
||||
| resolveScopedTenant() — NOT from Filament::getTenant(). So the tenant
|
||||
| data is available even though Filament::setTenant() is skipped.
|
||||
| Verify the page renders successfully and that the tenant's data is used.
|
||||
|
|
||||
*/
|
||||
|
||||
it('resolves scoped tenant correctly with workspace sidebar (context bar / US2)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// The page resolves $scopedTenant from route param and uses it for data display.
|
||||
// Verify the page title is present (static title: "Required permissions")
|
||||
$response->assertSee('Required permissions', false);
|
||||
|
||||
// The page uses $scopedTenant for links (e.g., "Re-run verification" links back to TenantResource)
|
||||
// If $scopedTenant were null, the page would abort(404) in mount().
|
||||
// The fact that we get 200 proves $scopedTenant resolved correctly despite setTenant() being skipped.
|
||||
|
||||
// Workspace nav present (sidebar fix working)
|
||||
$response->assertSee('Operations', false);
|
||||
|
||||
// Tenant nav absent (sidebar fix working)
|
||||
$response->assertDontSee('>Inventory</span>', false);
|
||||
});
|
||||
64
tests/Unit/OpsUx/SummaryCountsNormalizerTest.php
Normal file
64
tests/Unit/OpsUx/SummaryCountsNormalizerTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
|
||||
it('normalizes allowed keys and casts to int', function () {
|
||||
$result = SummaryCountsNormalizer::normalize([
|
||||
'total' => 5,
|
||||
'processed' => '3',
|
||||
'failed' => 0,
|
||||
'bogus_key' => 99,
|
||||
]);
|
||||
|
||||
expect($result)->toBe([
|
||||
'total' => 5,
|
||||
'processed' => 3,
|
||||
'failed' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips empty string keys and non-numeric values', function () {
|
||||
$result = SummaryCountsNormalizer::normalize([
|
||||
'' => 1,
|
||||
'total' => 'not-a-number',
|
||||
'succeeded' => 10,
|
||||
]);
|
||||
|
||||
expect($result)->toBe(['succeeded' => 10]);
|
||||
});
|
||||
|
||||
it('renders summary line with only non-zero values', function () {
|
||||
$line = SummaryCountsNormalizer::renderSummaryLine([
|
||||
'report_created' => 0,
|
||||
'report_deduped' => 1,
|
||||
'findings_created' => 0,
|
||||
'findings_unchanged' => 10,
|
||||
'alert_events_produced' => 0,
|
||||
]);
|
||||
|
||||
expect($line)->toBe('Report deduped: 1 · Findings unchanged: 10');
|
||||
});
|
||||
|
||||
it('returns null when all values are zero', function () {
|
||||
$line = SummaryCountsNormalizer::renderSummaryLine([
|
||||
'report_created' => 0,
|
||||
'findings_created' => 0,
|
||||
]);
|
||||
|
||||
expect($line)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty array', function () {
|
||||
expect(SummaryCountsNormalizer::renderSummaryLine([]))->toBeNull();
|
||||
});
|
||||
|
||||
it('humanizes snake_case keys to Title case', function () {
|
||||
$line = SummaryCountsNormalizer::renderSummaryLine([
|
||||
'high' => 3,
|
||||
'alert_events_produced' => 2,
|
||||
]);
|
||||
|
||||
expect($line)->toBe('High: 3 · Alert events produced: 2');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user