## Summary - complete Spec 136 canonical admin tenant rollout across admin-visible and shared Filament surfaces - add the shared panel-aware tenant resolver helper, persisted filter-state synchronization, and admin navigation segregation for tenant-sensitive resources - expand regression, guard, and parity coverage for admin-path tenant resolution, stale filters, workspace-wide tenant-default surfaces, and panel split behavior ## Validation - `vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php` - `vendor/bin/sail artisan test --compact --filter='CanonicalAdminTenantFilterState|PolicyResource|BackupSchedule|BackupSet|FindingResource|BaselineCompareLanding|RestoreRunResource|InventoryItemResource|PolicyVersionResource|ProviderConnectionResource|TenantDiagnostics|InventoryCoverage|InventoryKpiHeader|AuditLog|EntraGroup'` - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4.0+ compliance is preserved with Filament v5. - Provider registration remains unchanged in `bootstrap/providers.php`. - `PolicyResource` and `PolicyVersionResource` have admin global search disabled explicitly; `EntraGroupResource` keeps admin-aware scoped search with a View page. - Destructive and governance-sensitive actions retain existing confirmation and authorization behavior while using canonical tenant parity. - No new assets were introduced, so deployment asset strategy is unchanged and does not add new `filament:assets` work. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #165
291 lines
9.0 KiB
PHP
291 lines
9.0 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<string, mixed>
|
|
*/
|
|
public function buildEvidenceMap(?array $snapshot, ?string $platform = null): array
|
|
{
|
|
return $this->flattenForDiff($snapshot, 'intuneRoleDefinition', $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->buildEvidenceMap($baselineSnapshot, $platform);
|
|
$current = $this->buildEvidenceMap($currentSnapshot, $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 ');
|
|
}
|
|
}
|