## 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
233 lines
9.5 KiB
PHP
233 lines
9.5 KiB
PHP
<?php
|
|
|
|
use App\Services\Graph\GraphContractRegistry;
|
|
use App\Services\Graph\GraphResponse;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
beforeEach(function () {
|
|
config()->set('graph_contracts.types.deviceConfiguration', [
|
|
'resource' => 'deviceManagement/deviceConfigurations',
|
|
'allowed_select' => ['id', 'displayName'],
|
|
'allowed_expand' => ['assignments', 'roleDefinition($select=id,displayName)'],
|
|
'type_family' => [
|
|
'#microsoft.graph.deviceConfiguration',
|
|
'#microsoft.graph.windows10CustomConfiguration',
|
|
],
|
|
]);
|
|
|
|
config()->set('graph_contracts.types.settingsCatalogPolicy', [
|
|
'resource' => 'deviceManagement/configurationPolicies',
|
|
'allowed_select' => ['id'],
|
|
'allowed_expand' => [],
|
|
'type_family' => ['#microsoft.graph.deviceManagementConfigurationPolicy'],
|
|
'update_whitelist' => ['name', 'description'],
|
|
'update_map' => ['displayName' => 'name'],
|
|
'update_strip_keys' => ['platforms', 'technologies', 'templateReference', 'assignments'],
|
|
'settings_write' => [
|
|
'path_template' => 'deviceManagement/configurationPolicies/{id}/settings/{settingId}',
|
|
'method' => 'PATCH',
|
|
],
|
|
]);
|
|
|
|
config()->set('graph_contracts.types.intuneRoleDefinition', [
|
|
'resource' => 'deviceManagement/roleDefinitions',
|
|
'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn', 'rolePermissions', 'roleScopeTagIds'],
|
|
'allowed_expand' => [],
|
|
'type_family' => [
|
|
'#microsoft.graph.roleDefinition',
|
|
'#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
|
],
|
|
]);
|
|
|
|
config()->set('graph_contracts.types.intuneRoleAssignment', [
|
|
'resource' => 'deviceManagement/roleAssignments',
|
|
'allowed_select' => ['id', 'displayName', 'description', 'members', 'scopeMembers', 'resourceScopes', 'scopeType'],
|
|
'allowed_expand' => ['roleDefinition($select=id,displayName,description,isBuiltIn)'],
|
|
'type_family' => [
|
|
'#microsoft.graph.roleAssignment',
|
|
'#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
|
],
|
|
]);
|
|
|
|
$this->registry = app(GraphContractRegistry::class);
|
|
});
|
|
|
|
it('sanitizes disallowed select and expand values', function () {
|
|
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
|
|
'$select' => ['id', 'displayName', 'unsupported'],
|
|
'$expand' => ['assignments', 'badExpand'],
|
|
]);
|
|
$query = $result['query'];
|
|
$warnings = $result['warnings'];
|
|
|
|
expect($query['$select'])->toBe('id,displayName');
|
|
expect($query['$expand'])->toBe('assignments');
|
|
expect($warnings)->not->toBeEmpty();
|
|
});
|
|
|
|
it('splits $expand strings on top-level commas and preserves commas inside parentheses', function () {
|
|
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
|
|
'$expand' => 'assignments, roleDefinition($select=id,displayName)',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('assignments,roleDefinition($select=id,displayName)');
|
|
});
|
|
|
|
it('dedupes and drops empty $expand tokens', function () {
|
|
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
|
|
'$expand' => 'assignments, assignments,,',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('assignments');
|
|
});
|
|
|
|
it('caps $expand token length and maximum allowed items', function () {
|
|
$longToken = str_repeat('a', 201);
|
|
|
|
$allowedTokens = [];
|
|
foreach (range(1, 11) as $number) {
|
|
$allowedTokens[] = "token{$number}";
|
|
}
|
|
|
|
config()->set('graph_contracts.types.deviceConfiguration.allowed_expand', array_merge(['assignments', $longToken], $allowedTokens));
|
|
|
|
$result = $this->registry->sanitizeQuery('deviceConfiguration', [
|
|
'$expand' => array_merge([$longToken, 'assignments'], $allowedTokens),
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe(implode(',', array_merge(['assignments'], array_slice($allowedTokens, 0, 9))));
|
|
});
|
|
|
|
it('drops disallowed $expand tokens with exact-match allowlists', function () {
|
|
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
|
|
'$expand' => 'principal,principal($select=displayName)',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('principal');
|
|
});
|
|
|
|
it('emits non-production diagnostics when $expand is sanitized', function () {
|
|
Log::spy();
|
|
|
|
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
|
|
'$expand' => 'principal,badExpand',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('principal');
|
|
|
|
Log::shouldHaveReceived('warning')
|
|
->once()
|
|
->withArgs(function (string $message, array $context): bool {
|
|
return $message === 'Graph query sanitized'
|
|
&& ($context['policy_type'] ?? null) === 'entraRoleAssignments'
|
|
&& ($context['query_key'] ?? null) === '$expand'
|
|
&& in_array('badExpand', $context['removed'] ?? [], true);
|
|
});
|
|
});
|
|
|
|
it('uses debug-level diagnostics in production when $expand is sanitized', function () {
|
|
Log::spy();
|
|
|
|
$originalEnvironment = app()->environment();
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$result = $this->registry->sanitizeQuery('entraRoleAssignments', [
|
|
'$expand' => 'principal,badExpand',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('principal');
|
|
|
|
Log::shouldHaveReceived('debug')
|
|
->once()
|
|
->withArgs(function (string $message, array $context): bool {
|
|
return $message === 'Graph query sanitized'
|
|
&& ($context['policy_type'] ?? null) === 'entraRoleAssignments'
|
|
&& ($context['query_key'] ?? null) === '$expand'
|
|
&& in_array('badExpand', $context['removed'] ?? [], true);
|
|
});
|
|
|
|
Log::shouldNotHaveReceived('warning');
|
|
|
|
app()->detectEnvironment(fn () => $originalEnvironment);
|
|
});
|
|
|
|
it('matches derived types within family', function () {
|
|
expect($this->registry->matchesTypeFamily('deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration'))->toBeTrue();
|
|
expect($this->registry->matchesTypeFamily('deviceConfiguration', '#microsoft.graph.androidCompliancePolicy'))->toBeFalse();
|
|
});
|
|
|
|
it('detects capability errors for downgrade', function () {
|
|
$response = new GraphResponse(
|
|
success: false,
|
|
data: [],
|
|
status: 400,
|
|
errors: [['message' => 'Request is invalid due to $select']]
|
|
);
|
|
|
|
$shouldDowngrade = $this->registry->shouldDowngradeOnCapabilityError($response, ['$select' => ['displayName']]);
|
|
|
|
expect($shouldDowngrade)->toBeTrue();
|
|
});
|
|
|
|
it('provides contract for settings catalog policies', function () {
|
|
$contract = config('graph_contracts.types.settingsCatalogPolicy');
|
|
|
|
expect($contract)->not->toBeEmpty();
|
|
expect($contract['resource'])->toBe('deviceManagement/configurationPolicies');
|
|
expect($this->registry->matchesTypeFamily('settingsCatalogPolicy', '#microsoft.graph.deviceManagementConfigurationPolicy'))->toBeTrue();
|
|
});
|
|
|
|
it('provides helper paths and contract families for inventory-grade RBAC contracts', function () {
|
|
expect($this->registry->intuneRoleDefinitionPolicyType())->toBe('intuneRoleDefinition');
|
|
expect($this->registry->intuneRoleDefinitionListPath())->toBe('/deviceManagement/roleDefinitions');
|
|
expect($this->registry->intuneRoleDefinitionItemPath('role-def/1'))->toBe('/deviceManagement/roleDefinitions/role-def%2F1');
|
|
|
|
expect($this->registry->intuneRoleAssignmentPolicyType())->toBe('intuneRoleAssignment');
|
|
expect($this->registry->intuneRoleAssignmentListPath())->toBe('/deviceManagement/roleAssignments');
|
|
expect($this->registry->intuneRoleAssignmentItemPath('assignment/1'))->toBe('/deviceManagement/roleAssignments/assignment%2F1');
|
|
|
|
expect($this->registry->matchesTypeFamily('intuneRoleDefinition', '#microsoft.graph.roleDefinition'))->toBeTrue();
|
|
expect($this->registry->matchesTypeFamily('intuneRoleAssignment', '#microsoft.graph.deviceAndAppManagementRoleAssignment'))->toBeTrue();
|
|
});
|
|
|
|
it('sanitizes role-assignment expands against the inventory-grade RBAC contract', function () {
|
|
$result = $this->registry->sanitizeQuery('intuneRoleAssignment', [
|
|
'$expand' => 'roleDefinition($select=id,displayName,description,isBuiltIn),badExpand',
|
|
]);
|
|
|
|
expect($result['query']['$expand'])->toBe('roleDefinition($select=id,displayName,description,isBuiltIn)');
|
|
expect($result['warnings'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('sanitizes update payloads for settings catalog policies', function () {
|
|
$payload = [
|
|
'id' => 'scp-1',
|
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
|
'displayName' => 'Config',
|
|
'Description' => 'desc',
|
|
'version' => 5,
|
|
'createdDateTime' => '2024-01-01T00:00:00Z',
|
|
'settings' => [
|
|
[
|
|
'id' => 'setting-1',
|
|
'settingInstance' => [
|
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
|
'settingDefinitionId' => 'setting_definition',
|
|
'simpleSettingValue' => ['value' => 'bar'],
|
|
],
|
|
],
|
|
],
|
|
'Platforms' => ['windows'],
|
|
'unknown' => 'drop-me',
|
|
];
|
|
|
|
$sanitized = $this->registry->sanitizeUpdatePayload('settingsCatalogPolicy', $payload);
|
|
|
|
expect($sanitized)->toHaveKeys(['name', 'description']);
|
|
expect($sanitized)->not->toHaveKey('id');
|
|
expect($sanitized)->not->toHaveKey('@odata.type');
|
|
expect($sanitized)->not->toHaveKey('version');
|
|
expect($sanitized)->not->toHaveKey('platforms');
|
|
expect($sanitized)->not->toHaveKey('settings');
|
|
});
|