TenantAtlas/app/Services/Intune/IntuneRoleDefinitionNormalizer.php
ahmido ef41c9193a feat: add Intune RBAC baseline compare support (#156)
## Summary
- add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels
- keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging
- add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries

## Verification
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- result: `71 passed (467 assertions)`

## Filament / Platform Notes
- Livewire compliance: unchanged and compatible with Livewire v4.0+
- Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location
- Global search: no new globally searchable resource added; existing global search behavior is unchanged
- Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged
- Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets`
- Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #156
2026-03-09 18:49:20 +00:00

283 lines
8.8 KiB
PHP

<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class IntuneRoleDefinitionNormalizer implements PolicyTypeNormalizer
{
private const string MetadataOnly = 'metadata_only';
private const string None = 'none';
private const string PermissionChange = 'permission_change';
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'intuneRoleDefinition';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = is_array($snapshot) ? $snapshot : [];
if ($snapshot === []) {
return [
'status' => 'warning',
'settings' => [],
'warnings' => ['No snapshot available.'],
];
}
$settings = [];
$warnings = [];
$summaryEntries = [];
$this->pushEntry($summaryEntries, 'Display name', Arr::get($snapshot, 'displayName'));
$this->pushEntry($summaryEntries, 'Description', Arr::get($snapshot, 'description'));
$isBuiltIn = Arr::get($snapshot, 'isBuiltIn');
if (is_bool($isBuiltIn)) {
$this->pushEntry($summaryEntries, 'Role source', $isBuiltIn ? 'Built-in' : 'Custom');
}
$permissionBlocks = $this->normalizePermissionBlocks(Arr::get($snapshot, 'rolePermissions', []));
$this->pushEntry($summaryEntries, 'Permission blocks', count($permissionBlocks));
$scopeTagIds = $this->normalizedStringList(Arr::get($snapshot, 'roleScopeTagIds', []));
$this->pushEntry($summaryEntries, 'Scope tag IDs', $scopeTagIds);
if ($summaryEntries !== []) {
$settings[] = [
'type' => 'keyValue',
'title' => 'Role definition',
'entries' => $summaryEntries,
];
}
foreach ($permissionBlocks as $index => $block) {
$entries = [];
$this->pushEntry($entries, 'Allowed actions', $block['allowed']);
$this->pushEntry($entries, 'Denied actions', $block['denied']);
$this->pushEntry($entries, 'Conditions', $block['conditions']);
if ($entries === []) {
continue;
}
$settings[] = [
'type' => 'keyValue',
'title' => sprintf('Permission block %d', $index + 1),
'entries' => $entries,
];
}
if ($permissionBlocks === []) {
$warnings[] = 'Role definition contains no expanded permission blocks.';
}
if ($settings === []) {
return [
'status' => 'warning',
'settings' => [],
'warnings' => array_values(array_unique(array_merge($warnings, ['Role definition snapshot contains no readable fields.']))),
];
}
return [
'status' => $warnings === [] ? 'ok' : 'warning',
'settings' => $settings,
'warnings' => array_values(array_unique($warnings)),
];
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenNormalizedForDiff(
$this->normalize($snapshot, $policyType, $platform),
);
}
/**
* @return array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }
*/
public function classifyDiff(?array $baselineSnapshot, ?array $currentSnapshot, ?string $platform = null): array
{
$baseline = $this->flattenForDiff($baselineSnapshot, 'intuneRoleDefinition', $platform);
$current = $this->flattenForDiff($currentSnapshot, 'intuneRoleDefinition', $platform);
$keys = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current))));
sort($keys, SORT_STRING);
$changedKeys = [];
$metadataKeys = [];
$permissionKeys = [];
foreach ($keys as $key) {
if (($baseline[$key] ?? null) === ($current[$key] ?? null)) {
continue;
}
$changedKeys[] = $key;
if ($this->isPermissionDiffKey($key)) {
$permissionKeys[] = $key;
continue;
}
$metadataKeys[] = $key;
}
$diffKind = match (true) {
$permissionKeys !== [] => self::PermissionChange,
$metadataKeys !== [] => self::MetadataOnly,
default => self::None,
};
return [
'baseline' => $baseline,
'current' => $current,
'changed_keys' => $changedKeys,
'metadata_keys' => $metadataKeys,
'permission_keys' => $permissionKeys,
'diff_kind' => $diffKind,
'diff_fingerprint' => hash(
'sha256',
json_encode([
'diff_kind' => $diffKind,
'changed_keys' => $changedKeys,
'baseline' => $baseline,
'current' => $current,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
),
];
}
/**
* @return array<int, array{allowed: array<int, string>, denied: array<int, string>, conditions: array<int, string>, fingerprint: string}>
*/
private function normalizePermissionBlocks(mixed $rolePermissions): array
{
if (! is_array($rolePermissions)) {
return [];
}
$normalized = [];
foreach ($rolePermissions as $permissionBlock) {
if (! is_array($permissionBlock)) {
continue;
}
$resourceActions = $permissionBlock['resourceActions'] ?? null;
$resourceActions = is_array($resourceActions) ? $resourceActions : [];
$allowed = [];
$denied = [];
$conditions = [];
foreach ($resourceActions as $resourceAction) {
if (! is_array($resourceAction)) {
continue;
}
$allowed = array_merge($allowed, $this->normalizedStringList($resourceAction['allowedResourceActions'] ?? []));
$denied = array_merge($denied, $this->normalizedStringList($resourceAction['notAllowedResourceActions'] ?? []));
$conditions = array_merge($conditions, $this->normalizedStringList([$resourceAction['condition'] ?? null]));
}
$allowed = array_values(array_unique($allowed));
sort($allowed);
$denied = array_values(array_unique($denied));
sort($denied);
$conditions = array_values(array_unique($conditions));
sort($conditions);
$block = [
'allowed' => $allowed,
'denied' => $denied,
'conditions' => $conditions,
];
$block['fingerprint'] = hash(
'sha256',
json_encode($block, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
$normalized[] = $block;
}
usort($normalized, fn (array $left, array $right): int => strcmp($left['fingerprint'], $right['fingerprint']));
return $normalized;
}
/**
* @param array<int, array{key: string, value: mixed}> $entries
*/
private function pushEntry(array &$entries, string $key, mixed $value): void
{
if ($value === null) {
return;
}
if (is_string($value) && $value === '') {
return;
}
if (is_array($value) && $value === []) {
return;
}
$entries[] = [
'key' => $key,
'value' => $value,
];
}
/**
* @return array<int, string>
*/
private function normalizedStringList(mixed $values): array
{
if (! is_array($values)) {
return [];
}
return array_values(array_filter(
array_map(
static fn (mixed $value): ?string => is_string($value) && $value !== '' ? $value : null,
$values,
),
static fn (?string $value): bool => $value !== null,
));
}
private function isPermissionDiffKey(string $key): bool
{
return $key === 'Role definition > Permission blocks'
|| str_starts_with($key, 'Permission block ');
}
}