From 52d6d016cdd6fbdde91cd5131e7d53bfb8093355 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 12:50:37 +0100 Subject: [PATCH] feat: scripts policy normalization --- app/Providers/AppServiceProvider.php | 2 + .../Intune/ScriptsPolicyNormalizer.php | 90 +++++++++++++++++++ specs/013-scripts-management/plan.md | 42 +++++++++ specs/013-scripts-management/tasks.md | 20 +++++ .../ScriptPoliciesNormalizedDisplayTest.php | 51 +++++++++++ tests/Unit/ScriptsPolicyNormalizerTest.php | 55 ++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 app/Services/Intune/ScriptsPolicyNormalizer.php create mode 100644 specs/013-scripts-management/plan.md create mode 100644 specs/013-scripts-management/tasks.md create mode 100644 tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php create mode 100644 tests/Unit/ScriptsPolicyNormalizerTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dbf8384..f6421d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; @@ -42,6 +43,7 @@ public function register(): void CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php new file mode 100644 index 0000000..1593604 --- /dev/null +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -0,0 +1,90 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $settings = []; + + $settings[] = ['key' => 'Type', 'value' => $policyType]; + + if (is_string($displayName) && $displayName !== '') { + $settings[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $settings[] = ['key' => 'Description', 'value' => $description]; + } + + // Script content and large blobs should not dominate normalized output. + // Keep only safe summary fields if present. + $contentKeys = [ + 'scriptContent', + 'scriptContentBase64', + 'detectionScriptContent', + 'remediationScriptContent', + ]; + + foreach ($contentKeys as $key) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $settings[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + } + } + + $schedule = Arr::get($snapshot, 'runSchedule'); + if (is_array($schedule) && $schedule !== []) { + $settings[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + } + + $frequency = Arr::get($snapshot, 'runFrequency'); + if (is_string($frequency) && $frequency !== '') { + $settings[] = ['key' => 'Run frequency', 'value' => $frequency]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $settings[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => [], + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenForDiff($normalized['settings'] ?? []); + } +} diff --git a/specs/013-scripts-management/plan.md b/specs/013-scripts-management/plan.md new file mode 100644 index 0000000..57970df --- /dev/null +++ b/specs/013-scripts-management/plan.md @@ -0,0 +1,42 @@ +# Plan: Scripts Management (013) + +**Branch**: `013-scripts-management` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments. + +## Scope + +### In scope +- Script policy types: + - `deviceManagementScript` + - `deviceShellScript` + - `deviceHealthScript` +- Readable “Normalized settings” output for the above types. +- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior). +- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata). + +### Out of scope +- Adding new UI flows or pages. +- Introducing new external services or background infrastructure. +- Changing how authentication/authorization works. + +## Approach +1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys). +2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure. +3. Register the normalizer in the application normalizer tag. +4. Add tests: + - Normalized output shape/stability for each type. + - Filament “Normalized settings” tab renders without errors for a version of each type. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed). +- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable. + +## Success Criteria +- Normalized settings views are readable and stable for all three script policy types. +- Restore execution remains safe and assignment behavior is unchanged/regression-free. +- Tests cover the new normalizer behavior and basic UI render. diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md new file mode 100644 index 0000000..81d7bd2 --- /dev/null +++ b/specs/013-scripts-management/tasks.md @@ -0,0 +1,20 @@ +# Tasks: Scripts Management (013) + +**Branch**: `013-scripts-management` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [ ] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). + +## Phase 2: UI Normalization +- [ ] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [ ] T003 Register the normalizer in `AppServiceProvider`. + +## Phase 3: Tests + Verification +- [ ] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [ ] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [ ] T006 Run targeted tests. +- [ ] T007 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php new file mode 100644 index 0000000..8f7e3cf --- /dev/null +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -0,0 +1,51 @@ +actingAs(User::factory()->create()); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'platform' => 'all', + 'display_name' => 'Script policy', + 'external_id' => 'policy-1', + ]); + + PolicyVersion::factory()->create([ + 'policy_id' => $policy->id, + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'snapshot' => [ + '@odata.type' => $odataType, + 'displayName' => 'Script policy', + 'description' => 'desc', + 'scriptContent' => str_repeat('X', 20), + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) + ->assertSuccessful(); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +})->with([ + ['deviceManagementScript', '#microsoft.graph.deviceManagementScript'], + ['deviceShellScript', '#microsoft.graph.deviceShellScript'], + ['deviceHealthScript', '#microsoft.graph.deviceHealthScript'], +]); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php new file mode 100644 index 0000000..4c88ecc --- /dev/null +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -0,0 +1,55 @@ + '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'description' => 'Does a thing', + 'scriptContent' => str_repeat('A', 10), + 'runFrequency' => 'weekly', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect(collect($result['settings'])->pluck('key')->all())->toContain('Display name'); +}); + +it('normalizes deviceShellScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContent' => str_repeat('B', 5), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); +}); + +it('normalizes deviceHealthScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => str_repeat('C', 3), + 'remediationScriptContent' => str_repeat('D', 4), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); +});