merge: agent session work
This commit is contained in:
commit
2e6e772ef9
@ -46,6 +46,14 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
'platform' => $policy->platform,
|
||||
];
|
||||
|
||||
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
||||
$select = $this->metadataOnlySelect($policy->policy_type);
|
||||
|
||||
if ($select !== []) {
|
||||
$options['select'] = $select;
|
||||
}
|
||||
}
|
||||
|
||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||
$options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)';
|
||||
}
|
||||
@ -110,6 +118,10 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
$metadataWarnings = $response->warnings ?? [$reason];
|
||||
}
|
||||
|
||||
if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
||||
$payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []);
|
||||
}
|
||||
|
||||
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
||||
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
|
||||
|
||||
@ -130,6 +142,55 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
];
|
||||
}
|
||||
|
||||
private function isMetadataOnlyPolicyType(string $policyType): bool
|
||||
{
|
||||
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
|
||||
if (($type['type'] ?? null) === $policyType) {
|
||||
return ($type['backup'] ?? null) === 'metadata-only';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function metadataOnlySelect(string $policyType): array
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$allowedSelect = $contract['allowed_select'] ?? [];
|
||||
|
||||
if (! is_array($allowedSelect)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$allowedSelect,
|
||||
static fn (mixed $key) => is_string($key) && $key !== '@odata.type'
|
||||
));
|
||||
}
|
||||
|
||||
private function filterMetadataOnlyPayload(string $policyType, array $payload): array
|
||||
{
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$allowedSelect = $contract['allowed_select'] ?? [];
|
||||
|
||||
if (! is_array($allowedSelect) || $allowedSelect === []) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
|
||||
foreach ($allowedSelect as $key) {
|
||||
if (is_string($key) && array_key_exists($key, $payload)) {
|
||||
$filtered[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate settings catalog policies with configuration settings subresource.
|
||||
*
|
||||
|
||||
@ -323,11 +323,50 @@
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.mobileApp',
|
||||
'#microsoft.graph.androidLobApp',
|
||||
'#microsoft.graph.androidStoreApp',
|
||||
'#microsoft.graph.androidManagedStoreApp',
|
||||
'#microsoft.graph.iosLobApp',
|
||||
'#microsoft.graph.iosStoreApp',
|
||||
'#microsoft.graph.iosVppApp',
|
||||
'#microsoft.graph.winGetApp',
|
||||
'#microsoft.graph.macOSLobApp',
|
||||
'#microsoft.graph.macOSMicrosoftEdgeApp',
|
||||
'#microsoft.graph.macOSMicrosoftDefenderApp',
|
||||
'#microsoft.graph.macOSDmgApp',
|
||||
'#microsoft.graph.macOSPkgApp',
|
||||
'#microsoft.graph.macOsVppApp',
|
||||
'#microsoft.graph.macOSWebClip',
|
||||
'#microsoft.graph.managedAndroidLobApp',
|
||||
'#microsoft.graph.managedAndroidStoreApp',
|
||||
'#microsoft.graph.managedIOSLobApp',
|
||||
'#microsoft.graph.managedIOSStoreApp',
|
||||
'#microsoft.graph.microsoftStoreForBusinessApp',
|
||||
'#microsoft.graph.officeSuiteApp',
|
||||
'#microsoft.graph.macOSOfficeSuiteApp',
|
||||
'#microsoft.graph.webApp',
|
||||
'#microsoft.graph.windowsWebApp',
|
||||
'#microsoft.graph.windowsAppX',
|
||||
'#microsoft.graph.windowsUniversalAppX',
|
||||
'#microsoft.graph.windowsMicrosoftEdgeApp',
|
||||
'#microsoft.graph.windowsMobileMSI',
|
||||
'#microsoft.graph.windowsPhone81AppXBundle',
|
||||
'#microsoft.graph.windowsPhone81AppX',
|
||||
'#microsoft.graph.windowsPhone81StoreApp',
|
||||
'#microsoft.graph.windowsPhoneXAP',
|
||||
'#microsoft.graph.windowsStoreApp',
|
||||
'#microsoft.graph.win32LobApp',
|
||||
'#microsoft.graph.win32CatalogApp',
|
||||
'#microsoft.graph.iOSiPadOSWebClip',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceAppManagement/mobileApps/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceAppManagement/mobileApps/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'mobileAppAssignments',
|
||||
],
|
||||
'assignmentFilter' => [
|
||||
'resource' => 'deviceManagement/assignmentFilters',
|
||||
|
||||
26
specs/008-apps-app-management/plan.md
Normal file
26
specs/008-apps-app-management/plan.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Implementation Plan: Apps (008)
|
||||
|
||||
**Branch**: `feat/008-apps-app-management`
|
||||
**Date**: 2025-12-29
|
||||
**Spec Source**: [spec.md](./spec.md)
|
||||
|
||||
## Summary
|
||||
Make `mobileApp` reliable in the existing Policy/Backup/Restore flows by:
|
||||
|
||||
- Extending Graph contract registry with app assignment endpoints and a realistic `@odata.type` family.
|
||||
- Enforcing metadata-only snapshots during capture (contract-driven filtering).
|
||||
- Adding targeted Pest tests for contracts + snapshot behavior.
|
||||
|
||||
## Execution Steps
|
||||
1. Update `config/graph_contracts.php` for `mobileApp`:
|
||||
- Add assignments list + assign action endpoints and payload key.
|
||||
- Expand `type_family` using known app types.
|
||||
2. Update `app/Services/Intune/PolicySnapshotService.php`:
|
||||
- For metadata-only types, request/select and persist only metadata keys (even if Graph falls back).
|
||||
3. Add/extend tests:
|
||||
- `tests/Unit/GraphContractRegistryActualDataTest.php` for `mobileApp` contract coverage.
|
||||
- `tests/Unit/PolicySnapshotServiceTest.php` for metadata-only filtering behavior.
|
||||
4. Run formatting + tests:
|
||||
- `./vendor/bin/pint --dirty`
|
||||
- `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php`
|
||||
|
||||
59
specs/008-apps-app-management/spec.md
Normal file
59
specs/008-apps-app-management/spec.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Feature Specification: Apps (Mobile Apps) Metadata-Only + Assignments
|
||||
|
||||
**Feature Branch**: `feat/008-apps-app-management`
|
||||
**Created**: 2025-12-29
|
||||
**Status**: Draft
|
||||
**Input**: `.specify/spec.md` (mobileApp scope) + `references/IntuneManagement-master/Documentation/AppTypes.json` (known app @odata.type values).
|
||||
|
||||
## Overview
|
||||
Add reliable **mobile app** (`mobileApp`) coverage for inventory, backup/version capture, and restore:
|
||||
|
||||
- Capture **metadata-only** snapshots for Intune apps (no content/binary workflows).
|
||||
- Capture and restore **assignments** using the Intune `/assignments` and `/assign` endpoints.
|
||||
- Accept common **derived `@odata.type`** values for apps to avoid false `odata_mismatch` failures.
|
||||
|
||||
## In Scope
|
||||
- Policy type: `mobileApp` (`deviceAppManagement/mobileApps`) in existing Policy/Backup/Restore flows.
|
||||
- Backup/version capture stores only metadata fields + `@odata.type` (contract-driven).
|
||||
- Restore updates metadata fields on existing apps and reapplies assignments (with group + assignment filter mapping).
|
||||
- UI continues to use the existing Policies/Versions/Backup/Restore Filament experience.
|
||||
|
||||
## Out of Scope (v1)
|
||||
- Downloading or re-uploading app binaries/content (Win32 content versions, files, etc.).
|
||||
- Creating complex missing apps from scratch (where Graph requires app-type-specific required fields).
|
||||
- App relationships (dependencies/supersedence) beyond what is already present in metadata.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Inventory and detail view for apps (Priority: P1)
|
||||
As an admin, I can see Intune apps in the Policies list and inspect app metadata and assignments safely.
|
||||
|
||||
**Independent Test**: Sync policies; open Policies list filtered to “Applications”; open an app and verify metadata is readable and assignments are shown (when captured).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given apps exist in Intune, when the admin syncs and opens Policies, then apps appear under type `Applications (Metadata only)`.
|
||||
2. Given an app is a derived type (e.g. Win32, iOS VPP), when viewed/restored, then it is considered in-family for `mobileApp` (no `odata_mismatch`).
|
||||
|
||||
### User Story 2 - Backup capture for apps (Priority: P1)
|
||||
As an admin, I can capture backups/versions of apps with metadata-only payloads and optional assignments.
|
||||
|
||||
**Independent Test**: Create a backup with assignments enabled including at least one app; confirm the stored payload is metadata-only and assignments are captured.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given app backup is configured as metadata-only, when a snapshot is captured, then only whitelisted metadata keys are persisted.
|
||||
2. Given assignments are enabled, when capturing, then assignments are fetched via the configured assignments endpoint and stored alongside the snapshot.
|
||||
|
||||
### User Story 3 - Restore assignments for apps (Priority: P1)
|
||||
As an admin, I can restore app assignments (and metadata where supported) using group mapping with clear skip/failure reasons.
|
||||
|
||||
**Independent Test**: Restore an app backup into a tenant where group IDs differ; verify assignments are created/skipped with the expected outcomes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given an app exists in the target tenant, when restore executes, then metadata fields are patched and assignments are applied via `/assign`.
|
||||
2. Given required group mappings are missing, when restore executes, then assignments are skipped with a human readable reason per assignment.
|
||||
3. Given the app does not exist (404), when restore executes, then the item fails with a clear reason (no attempt to create complex apps from metadata-only snapshots).
|
||||
|
||||
## Notes
|
||||
- `@odata.type` matching uses `config/graph_contracts.php` as the safety gate for restore execution.
|
||||
- App type-family values are sourced from `references/IntuneManagement-master/Documentation/AppTypes.json` and kept conservative.
|
||||
|
||||
17
specs/008-apps-app-management/tasks.md
Normal file
17
specs/008-apps-app-management/tasks.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Tasks: Apps (Mobile Apps) Metadata-Only + Assignments (008)
|
||||
|
||||
**Branch**: `feat/008-apps-app-management` | **Date**: 2025-12-29
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||
|
||||
## Phase 1: Contracts and Safety Gates
|
||||
- [ ] T001 Expand `mobileApp` Graph contract in `config/graph_contracts.php` (assignments endpoints + payload key + type family).
|
||||
|
||||
## Phase 2: Snapshot Capture (Metadata-Only)
|
||||
- [ ] T002 Enforce metadata-only snapshot capture for apps in `app/Services/Intune/PolicySnapshotService.php`.
|
||||
|
||||
## Phase 3: Tests and Verification
|
||||
- [ ] T003 Add contract coverage tests in `tests/Unit/GraphContractRegistryActualDataTest.php`.
|
||||
- [ ] T004 Add snapshot filtering tests in `tests/Unit/PolicySnapshotServiceTest.php`.
|
||||
- [ ] T005 Run tests: `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php`
|
||||
- [ ] T006 Run Pint: `./vendor/bin/pint --dirty`
|
||||
|
||||
@ -102,6 +102,20 @@
|
||||
->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('exposes mobile app assignment endpoints and type family', function () {
|
||||
$contract = $this->registry->get('mobileApp');
|
||||
|
||||
expect($contract)->not->toBeEmpty();
|
||||
expect($contract['assignments_list_path'] ?? null)
|
||||
->toBe('/deviceAppManagement/mobileApps/{id}/assignments');
|
||||
expect($contract['assignments_create_path'] ?? null)
|
||||
->toBe('/deviceAppManagement/mobileApps/{id}/assign');
|
||||
expect($contract['assignments_payload_key'] ?? null)
|
||||
->toBe('mobileAppAssignments');
|
||||
expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.win32LobApp'))->toBeTrue();
|
||||
expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('omits role scope tags from assignment filter selects', function () {
|
||||
$contract = $this->registry->get('assignmentFilter');
|
||||
|
||||
|
||||
@ -24,6 +24,22 @@ public function getPolicy(string $policyType, string $policyId, array $options =
|
||||
{
|
||||
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
|
||||
|
||||
if ($policyType === 'mobileApp') {
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
'displayName' => 'Contoso Portal',
|
||||
'publisher' => 'Contoso',
|
||||
'description' => 'Company Portal',
|
||||
'@odata.type' => '#microsoft.graph.win32LobApp',
|
||||
'createdDateTime' => '2025-01-01T00:00:00Z',
|
||||
'lastModifiedDateTime' => '2025-01-02T00:00:00Z',
|
||||
'installCommandLine' => 'setup.exe /quiet',
|
||||
'largeIcon' => ['type' => 'image/png', 'value' => '...'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
@ -107,3 +123,45 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requests[0][3]['expand'] ?? null)
|
||||
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('filters mobile app snapshots to metadata-only keys', function () {
|
||||
$client = new PolicySnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-apps',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'app-123',
|
||||
'policy_type' => 'mobileApp',
|
||||
'display_name' => 'Contoso Portal',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result['payload'])->toHaveKeys([
|
||||
'id',
|
||||
'displayName',
|
||||
'publisher',
|
||||
'description',
|
||||
'@odata.type',
|
||||
'createdDateTime',
|
||||
'lastModifiedDateTime',
|
||||
]);
|
||||
expect($result['payload'])->not->toHaveKey('installCommandLine');
|
||||
expect($result['payload'])->not->toHaveKey('largeIcon');
|
||||
expect($client->requests[0][0])->toBe('getPolicy');
|
||||
expect($client->requests[0][1])->toBe('mobileApp');
|
||||
expect($client->requests[0][2])->toBe('app-123');
|
||||
expect($client->requests[0][3]['select'] ?? null)->toBeArray();
|
||||
expect($client->requests[0][3]['select'])->toContain('displayName');
|
||||
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user