merge: agent session work

This commit is contained in:
Ahmed Darrazi 2025-12-29 14:04:13 +01:00
commit 2e6e772ef9
7 changed files with 274 additions and 0 deletions

View File

@ -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.
*

View File

@ -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',

View 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`

View 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.

View 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`

View File

@ -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');

View File

@ -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');
});