## Summary - add Intune RBAC role definitions and role assignments as foundation-backed inventory, backup, and versioned snapshot types - add RBAC-specific normalization, coverage, permission-warning handling, and preview-only restore safety behavior across existing Filament and service surfaces - add spec 127 artifacts, contracts, audits, and focused regression coverage for inventory, backup, versioning, verification, and authorization behavior ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryCoverageTableTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php tests/Unit/GraphContractRegistryTest.php tests/Unit/FoundationSnapshotServiceTest.php tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Unit/IntuneRoleAssignmentNormalizerTest.php` ## Notes - tasks in `specs/127-rbac-inventory-backup/tasks.md` are complete except `T041`, which is the documented manual QA validation step Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #155
249 lines
7.6 KiB
PHP
249 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use Illuminate\Support\Arr;
|
|
|
|
class IntuneRoleAssignmentNormalizer implements PolicyTypeNormalizer
|
|
{
|
|
public function __construct(
|
|
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
|
) {}
|
|
|
|
public function supports(string $policyType): bool
|
|
{
|
|
return $policyType === 'intuneRoleAssignment';
|
|
}
|
|
|
|
/**
|
|
* @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.'],
|
|
];
|
|
}
|
|
|
|
$warnings = [];
|
|
$settings = [];
|
|
$roleDefinition = Arr::get($snapshot, 'roleDefinition');
|
|
$roleDefinition = is_array($roleDefinition) ? $roleDefinition : [];
|
|
|
|
$roleDefinitionDisplay = $this->formatRoleDefinition($roleDefinition);
|
|
if ($roleDefinitionDisplay === null) {
|
|
$warnings[] = 'Role definition details were not expanded; using identifier fallback where possible.';
|
|
} elseif (($roleDefinition['displayName'] ?? null) === null && ($roleDefinition['id'] ?? null) !== null) {
|
|
$warnings[] = 'Role definition display name unavailable; using identifier fallback.';
|
|
}
|
|
|
|
$members = $this->normalizeSubjects(Arr::get($snapshot, 'members', []));
|
|
$scopeMembers = $this->normalizeSubjects(Arr::get($snapshot, 'scopeMembers', []));
|
|
$resourceScopes = $this->normalizedStringList(Arr::get($snapshot, 'resourceScopes', []));
|
|
|
|
$summaryEntries = [];
|
|
$this->pushEntry($summaryEntries, 'Assignment name', Arr::get($snapshot, 'displayName'));
|
|
$this->pushEntry($summaryEntries, 'Description', Arr::get($snapshot, 'description'));
|
|
$this->pushEntry($summaryEntries, 'Role definition', $roleDefinitionDisplay);
|
|
$this->pushEntry($summaryEntries, 'Scope type', Arr::get($snapshot, 'scopeType'));
|
|
$this->pushEntry($summaryEntries, 'Members count', count($members));
|
|
$this->pushEntry($summaryEntries, 'Scope members count', count($scopeMembers));
|
|
$this->pushEntry($summaryEntries, 'Resource scopes count', count($resourceScopes));
|
|
|
|
if ($summaryEntries !== []) {
|
|
$settings[] = [
|
|
'type' => 'keyValue',
|
|
'title' => 'Role assignment',
|
|
'entries' => $summaryEntries,
|
|
];
|
|
}
|
|
|
|
if ($members !== []) {
|
|
$settings[] = [
|
|
'type' => 'keyValue',
|
|
'title' => 'Members',
|
|
'entries' => [
|
|
[
|
|
'key' => 'Resolved members',
|
|
'value' => $members,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($scopeMembers !== []) {
|
|
$settings[] = [
|
|
'type' => 'keyValue',
|
|
'title' => 'Scope members',
|
|
'entries' => [
|
|
[
|
|
'key' => 'Resolved scope members',
|
|
'value' => $scopeMembers,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($resourceScopes !== []) {
|
|
$settings[] = [
|
|
'type' => 'keyValue',
|
|
'title' => 'Resource scopes',
|
|
'entries' => [
|
|
[
|
|
'key' => 'Scopes',
|
|
'value' => $resourceScopes,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($settings === []) {
|
|
return [
|
|
'status' => 'warning',
|
|
'settings' => [],
|
|
'warnings' => array_values(array_unique(array_merge($warnings, ['Role assignment 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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
];
|
|
}
|
|
|
|
private function formatRoleDefinition(array $roleDefinition): ?string
|
|
{
|
|
$displayName = Arr::get($roleDefinition, 'displayName');
|
|
$id = Arr::get($roleDefinition, 'id');
|
|
|
|
if (is_string($displayName) && $displayName !== '' && is_string($id) && $id !== '') {
|
|
return sprintf('%s (%s)', $displayName, $id);
|
|
}
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
return $displayName;
|
|
}
|
|
|
|
if (is_string($id) && $id !== '') {
|
|
return $id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeSubjects(mixed $subjects): array
|
|
{
|
|
if (! is_array($subjects)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($subjects as $subject) {
|
|
if (is_string($subject) && $subject !== '') {
|
|
$normalized[] = $subject;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! is_array($subject)) {
|
|
continue;
|
|
}
|
|
|
|
$display = Arr::get($subject, 'displayName')
|
|
?? Arr::get($subject, 'userPrincipalName')
|
|
?? Arr::get($subject, 'mail')
|
|
?? Arr::get($subject, 'name');
|
|
$identifier = Arr::get($subject, 'id')
|
|
?? Arr::get($subject, 'principalId')
|
|
?? Arr::get($subject, 'groupId');
|
|
|
|
if (is_string($display) && $display !== '' && is_string($identifier) && $identifier !== '' && $display !== $identifier) {
|
|
$normalized[] = sprintf('%s (%s)', $display, $identifier);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_string($display) && $display !== '') {
|
|
$normalized[] = $display;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_string($identifier) && $identifier !== '') {
|
|
$normalized[] = $identifier;
|
|
}
|
|
}
|
|
|
|
$normalized = array_values(array_unique($normalized));
|
|
sort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizedStringList(mixed $values): array
|
|
{
|
|
if (! is_array($values)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = 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,
|
|
));
|
|
|
|
$normalized = array_values(array_unique($normalized));
|
|
sort($normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
}
|