feat/017-policy-types-mam-endpoint-security-baselines #23
@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
||||
TextEntry::make('created_at')->since(),
|
||||
TextEntry::make('latest_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->badge()
|
||||
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
|
||||
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
|
||||
->helperText(function (Policy $record): ?string {
|
||||
$meta = static::latestVersionMetadata($record);
|
||||
|
||||
if (($meta['source'] ?? null) !== 'metadata_only') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = $meta['original_status'] ?? null;
|
||||
|
||||
return sprintf(
|
||||
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
$status ?? 'an error'
|
||||
);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function latestVersionMetadata(Policy $record): array
|
||||
{
|
||||
$metadata = $record->relationLoaded('versions')
|
||||
? $record->versions->first()?->metadata
|
||||
: $record->versions()->orderByDesc('captured_at')->value('metadata');
|
||||
|
||||
if (is_string($metadata)) {
|
||||
$decoded = json_decode($metadata, true);
|
||||
$metadata = $decoded ?? [];
|
||||
}
|
||||
|
||||
return is_array($metadata) ? $metadata : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -764,7 +798,7 @@ private static function settingsTabState(Policy $record): array
|
||||
$rows = $normalized['settings_table']['rows'] ?? [];
|
||||
$hasSettingsTable = is_array($rows) && $rows !== [];
|
||||
|
||||
if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) {
|
||||
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
return $split['normalized'];
|
||||
|
||||
@ -28,11 +28,35 @@ protected function getHeaderActions(): array
|
||||
/** @var PolicySyncService $service */
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$synced = $service->syncPolicies($tenant);
|
||||
$result = $service->syncPoliciesWithReport($tenant);
|
||||
$syncedCount = count($result['synced'] ?? []);
|
||||
$failureCount = count($result['failures'] ?? []);
|
||||
|
||||
$body = $syncedCount.' policies synced';
|
||||
|
||||
if ($failureCount > 0) {
|
||||
$first = $result['failures'][0] ?? [];
|
||||
$firstType = $first['policy_type'] ?? 'unknown';
|
||||
$firstStatus = $first['status'] ?? null;
|
||||
|
||||
$firstErrorMessage = null;
|
||||
$firstErrors = $first['errors'] ?? null;
|
||||
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
|
||||
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
|
||||
}
|
||||
|
||||
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
|
||||
|
||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||
$suffix .= ' - '.trim($firstErrorMessage);
|
||||
}
|
||||
|
||||
$body .= " ({$failureCount} failed; {$suffix})";
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy sync completed')
|
||||
->body(count($synced).' policies synced')
|
||||
->body($body)
|
||||
->success()
|
||||
->sendToDatabase(auth()->user())
|
||||
->send();
|
||||
|
||||
@ -49,7 +49,7 @@ protected function getActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
app(VersionService::class)->captureFromGraph(
|
||||
$version = app(VersionService::class)->captureFromGraph(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
createdBy: auth()->user()?->email ?? null,
|
||||
@ -57,10 +57,23 @@ protected function getActions(): array
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Snapshot captured successfully.')
|
||||
->success()
|
||||
->send();
|
||||
if (($version->metadata['source'] ?? null) === 'metadata_only') {
|
||||
$status = $version->metadata['original_status'] ?? null;
|
||||
|
||||
Notification::make()
|
||||
->title('Snapshot captured (metadata only)')
|
||||
->body(sprintf(
|
||||
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
|
||||
$status ?? 'an error'
|
||||
))
|
||||
->warning()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Snapshot captured successfully.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@ -34,6 +34,8 @@ public function table(Table $table): Table
|
||||
->label('Restore to Intune')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
|
||||
@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'),
|
||||
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
|
||||
Infolists\Components\ViewEntry::make('normalized_settings_standard')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
@ -91,7 +91,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return $normalized;
|
||||
})
|
||||
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'),
|
||||
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
->id('raw-json')
|
||||
@ -194,6 +194,8 @@ public static function table(Table $table): Table
|
||||
->label('Restore via Wizard')
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('primary')
|
||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
|
||||
@ -175,6 +175,9 @@ public function table(Table $table): Table
|
||||
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
|
||||
$beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
|
||||
$beforeFailureCount = count($beforeFailures);
|
||||
|
||||
$policyIds = $records->pluck('id')->all();
|
||||
|
||||
if ($policyIds === []) {
|
||||
@ -201,10 +204,23 @@ public function table(Table $table): Table
|
||||
? 'Backup items added'
|
||||
: 'Policies added to backup';
|
||||
|
||||
Notification::make()
|
||||
->title($notificationTitle)
|
||||
->success()
|
||||
->send();
|
||||
$backupSet->refresh();
|
||||
|
||||
$afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
|
||||
$afterFailureCount = count($afterFailures);
|
||||
|
||||
if ($afterFailureCount > $beforeFailureCount) {
|
||||
Notification::make()
|
||||
->title($notificationTitle.' with failures')
|
||||
->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.')
|
||||
->warning()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title($notificationTitle)
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->resetTable();
|
||||
}),
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
|
||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
|
||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||
@ -45,6 +46,7 @@ public function register(): void
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
EnrollmentAutopilotPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
ManagedDeviceAppConfigurationNormalizer::class,
|
||||
ScriptsPolicyNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
WindowsFeatureUpdateProfileNormalizer::class,
|
||||
|
||||
@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array
|
||||
: array_map('trim', explode(',', (string) $original));
|
||||
$filtered = array_values(array_intersect($select, $allowedSelect));
|
||||
|
||||
$withoutAnnotations = array_values(array_filter(
|
||||
$filtered,
|
||||
static fn ($field) => is_string($field) && ! str_contains($field, '@')
|
||||
));
|
||||
|
||||
if (count($withoutAnnotations) !== count($filtered)) {
|
||||
$warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).';
|
||||
$filtered = $withoutAnnotations;
|
||||
}
|
||||
|
||||
if (count($filtered) !== count($select)) {
|
||||
$warnings[] = 'Trimmed unsupported $select fields for capability safety.';
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface
|
||||
{
|
||||
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
|
||||
|
||||
private const MAX_LIST_PAGES = 50;
|
||||
|
||||
private string $baseUrl;
|
||||
|
||||
private string $tokenUrlTemplate;
|
||||
@ -51,12 +53,21 @@ public function __construct(
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
$endpoint = $this->endpointFor($policyType);
|
||||
$query = array_filter([
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
|
||||
$defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);
|
||||
|
||||
$queryInput = array_filter([
|
||||
'$top' => $options['top'] ?? null,
|
||||
'$filter' => $options['filter'] ?? null,
|
||||
'$select' => $defaultSelect,
|
||||
'platform' => $options['platform'] ?? null,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
|
||||
$query = $sanitized['query'];
|
||||
$warnings = $sanitized['warnings'];
|
||||
|
||||
$context = $this->resolveContext($options);
|
||||
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
||||
$fullPath = $this->buildFullPath($endpoint, $query);
|
||||
@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
|
||||
|
||||
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
||||
|
||||
return $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $response,
|
||||
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
if ($response->failed()) {
|
||||
$graphResponse = $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $response,
|
||||
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
'method' => 'GET',
|
||||
'query' => $query ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
],
|
||||
warnings: $warnings,
|
||||
);
|
||||
|
||||
if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
|
||||
return $graphResponse;
|
||||
}
|
||||
|
||||
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
|
||||
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
|
||||
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
|
||||
|
||||
if (isset($options['access_token'])) {
|
||||
$fallbackSendOptions['access_token'] = $options['access_token'];
|
||||
}
|
||||
|
||||
$this->logger->logRequest('list_policies_fallback', [
|
||||
'endpoint' => $endpoint,
|
||||
'full_path' => $fallbackPath,
|
||||
'method' => 'GET',
|
||||
'query' => $query ?: null,
|
||||
'policy_type' => $policyType,
|
||||
'tenant' => $context['tenant'],
|
||||
'query' => $fallbackQuery ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
]
|
||||
]);
|
||||
|
||||
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
|
||||
|
||||
if ($fallbackResponse->failed()) {
|
||||
return $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $fallbackResponse,
|
||||
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fallbackPath,
|
||||
'method' => 'GET',
|
||||
'query' => $fallbackQuery ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
],
|
||||
warnings: array_values(array_unique(array_merge(
|
||||
$warnings,
|
||||
['Capability fallback applied: removed $select for compatibility.']
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
$response = $fallbackResponse;
|
||||
$query = $fallbackQuery;
|
||||
$fullPath = $fallbackPath;
|
||||
$warnings = array_values(array_unique(array_merge(
|
||||
$warnings,
|
||||
['Capability fallback applied: removed $select for compatibility.']
|
||||
)));
|
||||
}
|
||||
|
||||
$json = $response->json() ?? [];
|
||||
$policies = $json['value'] ?? (is_array($json) ? $json : []);
|
||||
$nextLink = $json['@odata.nextLink'] ?? null;
|
||||
$pages = 1;
|
||||
|
||||
while (is_string($nextLink) && $nextLink !== '') {
|
||||
if ($pages >= self::MAX_LIST_PAGES) {
|
||||
$graphResponse = new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 500,
|
||||
errors: [[
|
||||
'message' => 'Graph pagination exceeded maximum page limit.',
|
||||
'max_pages' => self::MAX_LIST_PAGES,
|
||||
]],
|
||||
warnings: $warnings,
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
'method' => 'GET',
|
||||
'query' => $query ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
'pages_fetched' => $pages,
|
||||
],
|
||||
);
|
||||
|
||||
$this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);
|
||||
|
||||
return $graphResponse;
|
||||
}
|
||||
|
||||
$pageOptions = ['client_request_id' => $clientRequestId];
|
||||
|
||||
if (isset($options['access_token'])) {
|
||||
$pageOptions['access_token'] = $options['access_token'];
|
||||
}
|
||||
|
||||
$pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);
|
||||
|
||||
if ($pageResponse->failed()) {
|
||||
$graphResponse = $this->toGraphResponse(
|
||||
action: 'list_policies',
|
||||
response: $pageResponse,
|
||||
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||
meta: [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
'method' => 'GET',
|
||||
'query' => $query ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
'pages_fetched' => $pages,
|
||||
],
|
||||
warnings: array_values(array_unique(array_merge(
|
||||
$warnings,
|
||||
['Pagination failed while listing policies.']
|
||||
))),
|
||||
);
|
||||
|
||||
return $graphResponse;
|
||||
}
|
||||
|
||||
$pageJson = $pageResponse->json() ?? [];
|
||||
$pageValue = $pageJson['value'] ?? [];
|
||||
|
||||
if (is_array($pageValue) && $pageValue !== []) {
|
||||
$policies = array_merge($policies, $pageValue);
|
||||
}
|
||||
|
||||
$nextLink = $pageJson['@odata.nextLink'] ?? null;
|
||||
$pages++;
|
||||
}
|
||||
|
||||
$meta = $this->responseMeta($response, [
|
||||
'tenant' => $context['tenant'] ?? null,
|
||||
'path' => $endpoint,
|
||||
'full_path' => $fullPath,
|
||||
'method' => 'GET',
|
||||
'query' => $query ?: null,
|
||||
'client_request_id' => $clientRequestId,
|
||||
]);
|
||||
|
||||
$meta['pages_fetched'] = $pages;
|
||||
$meta['item_count'] = count($policies);
|
||||
|
||||
if ($pages > 1) {
|
||||
$warnings = array_values(array_unique(array_merge($warnings, [
|
||||
sprintf('Pagination applied: fetched %d pages.', $pages),
|
||||
])));
|
||||
}
|
||||
|
||||
$graphResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: $policies,
|
||||
status: $response->status(),
|
||||
warnings: $warnings,
|
||||
meta: $meta,
|
||||
);
|
||||
|
||||
$this->logger->logResponse('list_policies', $graphResponse, $meta);
|
||||
|
||||
return $graphResponse;
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options =
|
||||
return $graphResponse;
|
||||
}
|
||||
|
||||
private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
|
||||
{
|
||||
if (! $graphResponse->failed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($graphResponse->status ?? null) !== 400) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_key_exists('$select', $query)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$errorMessage = $graphResponse->meta['error_message'] ?? null;
|
||||
|
||||
if (! is_string($errorMessage) || $errorMessage === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stripos($errorMessage, 'Could not find a property named') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
$context = $this->resolveContext($options);
|
||||
@ -575,6 +776,11 @@ private function normalizeScopes(array|string|null $scope): array
|
||||
|
||||
private function endpointFor(string $policyType): string
|
||||
{
|
||||
$contractResource = $this->contracts->resourcePath($policyType);
|
||||
if (is_string($contractResource) && $contractResource !== '') {
|
||||
return $contractResource;
|
||||
}
|
||||
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
foreach ($supported as $type) {
|
||||
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
|
||||
|
||||
@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
$resultWarnings = [];
|
||||
$status = 'success';
|
||||
$settingsTable = null;
|
||||
$usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||
$fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot);
|
||||
|
||||
$validation = $this->validator->validate($snapshot);
|
||||
$resultWarnings = array_merge($resultWarnings, $validation['warnings']);
|
||||
@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
||||
}
|
||||
|
||||
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
|
||||
if ($policyType === 'settingsCatalogPolicy') {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
|
||||
if ($usesSettingsCatalogTable) {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable(
|
||||
$snapshot['settings'],
|
||||
fallbackCategoryName: $fallbackCategoryName
|
||||
);
|
||||
$settingsTable = $normalized['table'];
|
||||
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
||||
} else {
|
||||
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
|
||||
}
|
||||
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
|
||||
if ($policyType === 'settingsCatalogPolicy') {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
|
||||
if ($usesSettingsCatalogTable) {
|
||||
$normalized = $this->buildSettingsCatalogSettingsTable(
|
||||
$snapshot['settingsDelta'],
|
||||
'Settings delta',
|
||||
$fallbackCategoryName
|
||||
);
|
||||
$settingsTable = $normalized['table'];
|
||||
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
||||
} else {
|
||||
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
|
||||
}
|
||||
} elseif ($policyType === 'settingsCatalogPolicy') {
|
||||
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
|
||||
} elseif ($usesSettingsCatalogTable) {
|
||||
$resultWarnings[] = 'Settings not hydrated for this Configuration Policy.';
|
||||
}
|
||||
|
||||
$settings[] = $this->normalizeStandard($snapshot);
|
||||
@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett
|
||||
];
|
||||
}
|
||||
|
||||
private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string
|
||||
{
|
||||
$templateReference = $snapshot['templateReference'] ?? null;
|
||||
|
||||
if (is_string($templateReference)) {
|
||||
$decoded = json_decode($templateReference, true);
|
||||
$templateReference = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
if (! is_array($templateReference)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayName = $templateReference['templateDisplayName'] ?? null;
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
$family = $templateReference['templateFamily'] ?? null;
|
||||
|
||||
if (is_string($family) && $family !== '') {
|
||||
return Str::headline($family);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $settings
|
||||
* @return array{table: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array
|
||||
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array
|
||||
{
|
||||
$flattened = $this->flattenSettingsCatalogSettingInstances($settings);
|
||||
$flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName);
|
||||
|
||||
return [
|
||||
'table' => [
|
||||
@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl
|
||||
* @param array<int, mixed> $settings
|
||||
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
|
||||
*/
|
||||
private function flattenSettingsCatalogSettingInstances(array $settings): array
|
||||
private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array
|
||||
{
|
||||
$rows = [];
|
||||
$warnings = [];
|
||||
@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
|
||||
&$warnedRowLimit,
|
||||
$definitions,
|
||||
$categories,
|
||||
$defaultCategoryName
|
||||
$defaultCategoryName,
|
||||
$fallbackCategoryName,
|
||||
): void {
|
||||
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
|
||||
if (! $warnedRowLimit) {
|
||||
@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
|
||||
$categoryName = $defaultCategoryName;
|
||||
}
|
||||
|
||||
if (
|
||||
$categoryName === '-'
|
||||
&& is_string($fallbackCategoryName)
|
||||
&& $fallbackCategoryName !== ''
|
||||
&& is_array($definition)
|
||||
&& ($definition['isFallback'] ?? false)
|
||||
) {
|
||||
$categoryName = $fallbackCategoryName;
|
||||
}
|
||||
|
||||
// Convert technical type to user-friendly data type
|
||||
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
|
||||
|
||||
@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
|
||||
$type = $instance['@odata.type'] ?? null;
|
||||
$type = is_string($type) ? $type : '';
|
||||
|
||||
if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) {
|
||||
$collection = $instance['choiceSettingCollectionValue'] ?? null;
|
||||
|
||||
if (! is_array($collection) || $collection === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
||||
foreach ($collection as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $item['value'] ?? null;
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$values[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($values));
|
||||
}
|
||||
|
||||
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
|
||||
$simple = $instance['simpleSettingValue'] ?? null;
|
||||
|
||||
if (is_array($simple)) {
|
||||
return $simple['value'] ?? $simple;
|
||||
$simpleValue = $simple['value'] ?? $simple;
|
||||
|
||||
if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) {
|
||||
return $simpleValue['value'];
|
||||
}
|
||||
|
||||
return $simpleValue;
|
||||
}
|
||||
|
||||
return $simple;
|
||||
@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
|
||||
$choice = $instance['choiceSettingValue'] ?? null;
|
||||
|
||||
if (is_array($choice)) {
|
||||
return $choice['value'] ?? $choice;
|
||||
$choiceValue = $choice['value'] ?? $choice;
|
||||
|
||||
if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) {
|
||||
return $choiceValue['value'];
|
||||
}
|
||||
|
||||
return $choiceValue;
|
||||
}
|
||||
|
||||
return $choice;
|
||||
@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
if (is_string($value)) {
|
||||
// Remove {tenantid} placeholder
|
||||
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
|
||||
$value = preg_replace('/\{[^}]+\}/', '', $value);
|
||||
$value = preg_replace('/_+/', '_', $value);
|
||||
|
||||
// Extract choice label from choice values (last meaningful part)
|
||||
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0"
|
||||
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) {
|
||||
if (
|
||||
str_contains($value, 'device_vendor_msft')
|
||||
|| str_contains($value, 'user_vendor_msft')
|
||||
|| str_contains($value, 'vendor_msft')
|
||||
|| str_contains($value, '#microsoft.graph')
|
||||
) {
|
||||
$parts = explode('_', $value);
|
||||
$lastPart = end($parts);
|
||||
|
||||
@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
$commonLastPartMapping = [
|
||||
'in' => 'Inbound',
|
||||
'out' => 'Outbound',
|
||||
'allow' => 'Allow',
|
||||
'block' => 'Block',
|
||||
'tcp' => 'TCP',
|
||||
'udp' => 'UDP',
|
||||
'icmpv4' => 'ICMPv4',
|
||||
'icmpv6' => 'ICMPv6',
|
||||
'any' => 'Any',
|
||||
'notconfigured' => 'Not configured',
|
||||
'lan' => 'LAN',
|
||||
'wireless' => 'Wireless',
|
||||
'remoteaccess' => 'Remote access',
|
||||
'domain' => 'Domain',
|
||||
'private' => 'Private',
|
||||
'public' => 'Public',
|
||||
];
|
||||
|
||||
if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) {
|
||||
return $commonLastPartMapping[strtolower($lastPart)];
|
||||
}
|
||||
|
||||
// If last part is just a number, take second-to-last too
|
||||
if (is_numeric($lastPart) && count($parts) > 1) {
|
||||
$secondLast = $parts[count($parts) - 2];
|
||||
@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
if ($value === []) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
$parts = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) {
|
||||
$parts = [];
|
||||
break;
|
||||
}
|
||||
|
||||
$parts[] = $this->formatSettingsCatalogValue($item);
|
||||
}
|
||||
|
||||
$parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-')));
|
||||
|
||||
if ($parts !== []) {
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
return json_encode($value);
|
||||
}
|
||||
|
||||
|
||||
143
app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php
Normal file
143
app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ManagedDeviceAppConfigurationNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'managedDeviceAppConfiguration';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
if ($snapshot === []) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$normalized['settings'] = array_values(array_filter(
|
||||
$normalized['settings'],
|
||||
static function (array $block): bool {
|
||||
$title = strtolower((string) ($block['title'] ?? ''));
|
||||
|
||||
return $title !== 'settings' && $title !== 'settings delta';
|
||||
}
|
||||
));
|
||||
|
||||
$rows = $this->buildSettingsRows($snapshot['settings'] ?? null);
|
||||
|
||||
if ($rows !== []) {
|
||||
$normalized['settings'][] = [
|
||||
'type' => 'table',
|
||||
'title' => 'App configuration settings',
|
||||
'rows' => $rows,
|
||||
];
|
||||
} else {
|
||||
$normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.';
|
||||
$normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== '')));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$snapshot = $snapshot ?? [];
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildSettingsRows(mixed $settings): array
|
||||
{
|
||||
if (! is_array($settings) || $settings === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
if (! is_array($setting)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $setting['appConfigKey'] ?? null;
|
||||
$rawValue = $setting['appConfigKeyValue'] ?? null;
|
||||
$type = $setting['appConfigKeyType'] ?? null;
|
||||
|
||||
if (! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->normalizeValue($rawValue, $type);
|
||||
|
||||
$rows[] = [
|
||||
'path' => $key,
|
||||
'label' => $key,
|
||||
'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
'description' => is_string($type) && $type !== '' ? Str::headline($type) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value, mixed $type): mixed
|
||||
{
|
||||
$type = is_string($type) ? strtolower($type) : '';
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value);
|
||||
|
||||
if ($type !== '' && str_contains($type, 'boolean')) {
|
||||
if (in_array(strtolower($trimmed), ['true', 'false'], true)) {
|
||||
return strtolower($trimmed) === 'true';
|
||||
}
|
||||
|
||||
if (in_array(strtolower($trimmed), ['yes', 'no'], true)) {
|
||||
return strtolower($trimmed) === 'yes';
|
||||
}
|
||||
|
||||
if (in_array($trimmed, ['1', '0'], true)) {
|
||||
return $trimmed === '1';
|
||||
}
|
||||
}
|
||||
|
||||
if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) {
|
||||
if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) {
|
||||
return (int) $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -47,13 +47,21 @@ public function capture(
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
|
||||
|
||||
if (isset($snapshot['failure'])) {
|
||||
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot');
|
||||
return [
|
||||
'failure' => $snapshot['failure'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$assignments = null;
|
||||
$scopeTags = null;
|
||||
$captureMetadata = [];
|
||||
$captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
|
||||
|
||||
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
|
||||
if ($snapshotWarnings !== []) {
|
||||
$existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : [];
|
||||
$captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
|
||||
}
|
||||
|
||||
// 2. Fetch assignments if requested
|
||||
if ($includeAssignments) {
|
||||
@ -179,9 +187,9 @@ public function capture(
|
||||
|
||||
// 5. Create new PolicyVersion with all captured data
|
||||
$metadata = array_merge(
|
||||
['source' => 'orchestrated_capture'],
|
||||
['capture_source' => 'orchestrated_capture'],
|
||||
$metadata,
|
||||
$captureMetadata
|
||||
$captureMetadata,
|
||||
);
|
||||
|
||||
$version = $this->versionService->captureVersion(
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
} catch (Throwable $throwable) {
|
||||
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
|
||||
|
||||
// For certain policy types experiencing upstream Graph issues, fall back to metadata-only
|
||||
if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) {
|
||||
return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status);
|
||||
}
|
||||
|
||||
return [
|
||||
'failure' => [
|
||||
'policy_id' => $policy->id,
|
||||
@ -87,8 +93,9 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
);
|
||||
}
|
||||
|
||||
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
||||
[$payload, $metadata] = $this->hydrateSettingsCatalog(
|
||||
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
|
||||
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
|
||||
policyType: $policy->policy_type,
|
||||
tenantIdentifier: $tenantIdentifier,
|
||||
tenant: $tenant,
|
||||
policyId: $policy->external_id,
|
||||
@ -118,7 +125,12 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$reason = $response->warnings[0] ?? 'Graph request failed';
|
||||
$reason = $this->formatGraphFailureReason($response);
|
||||
|
||||
if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) {
|
||||
return $this->createMetadataOnlySnapshot($policy, $reason, $response->status);
|
||||
}
|
||||
|
||||
$failure = [
|
||||
'policy_id' => $policy->id,
|
||||
'reason' => $reason,
|
||||
@ -162,6 +174,47 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
||||
];
|
||||
}
|
||||
|
||||
private function formatGraphFailureReason(GraphResponse $response): string
|
||||
{
|
||||
$code = $response->meta['error_code']
|
||||
?? ($response->errors[0]['code'] ?? null)
|
||||
?? ($response->data['error']['code'] ?? null);
|
||||
|
||||
$message = $response->meta['error_message']
|
||||
?? ($response->errors[0]['message'] ?? null)
|
||||
?? ($response->data['error']['message'] ?? null)
|
||||
?? ($response->warnings[0] ?? null);
|
||||
|
||||
$reason = 'Graph request failed';
|
||||
|
||||
if (is_string($message) && $message !== '') {
|
||||
$reason = $message;
|
||||
}
|
||||
|
||||
if (is_string($code) && $code !== '') {
|
||||
$reason = sprintf('%s: %s', $code, $reason);
|
||||
}
|
||||
|
||||
$requestId = $response->meta['request_id'] ?? null;
|
||||
$clientRequestId = $response->meta['client_request_id'] ?? null;
|
||||
|
||||
$suffixParts = [];
|
||||
|
||||
if (is_string($clientRequestId) && $clientRequestId !== '') {
|
||||
$suffixParts[] = sprintf('client_request_id=%s', $clientRequestId);
|
||||
}
|
||||
|
||||
if (is_string($requestId) && $requestId !== '') {
|
||||
$suffixParts[] = sprintf('request_id=%s', $requestId);
|
||||
}
|
||||
|
||||
if ($suffixParts !== []) {
|
||||
$reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts));
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate Windows Update Ring payload via derived type cast to capture
|
||||
* windowsUpdateForBusinessConfiguration-specific properties.
|
||||
@ -263,14 +316,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload):
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate settings catalog policies with configuration settings subresource.
|
||||
* Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines).
|
||||
*
|
||||
* @return array{0:array,1:array}
|
||||
*/
|
||||
private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
||||
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
||||
{
|
||||
$strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy');
|
||||
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId);
|
||||
$strategy = $this->contracts->memberHydrationStrategy($policyType);
|
||||
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
|
||||
|
||||
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
|
||||
return [$payload, $metadata];
|
||||
@ -592,6 +645,69 @@ private function stripGraphBaseUrl(string $nextLink): string
|
||||
return ltrim(substr($nextLink, strlen($base)), '/');
|
||||
}
|
||||
|
||||
return ltrim($nextLink, '/');
|
||||
return $nextLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should fall back to metadata-only for this policy type and error.
|
||||
*/
|
||||
private function shouldFallbackToMetadata(string $policyType, ?int $status): bool
|
||||
{
|
||||
// Only fallback on 5xx server errors
|
||||
if ($status === null || $status < 500 || $status >= 600) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enable fallback for policy types experiencing upstream Graph issues
|
||||
$fallbackTypes = [
|
||||
'mamAppConfiguration',
|
||||
'managedDeviceAppConfiguration',
|
||||
];
|
||||
|
||||
return in_array($policyType, $fallbackTypes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a metadata-only snapshot from the Policy model when Graph is unavailable.
|
||||
*
|
||||
* @return array{payload:array,metadata:array,warnings:array}
|
||||
*/
|
||||
private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array
|
||||
{
|
||||
$odataType = match ($policy->policy_type) {
|
||||
'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
|
||||
default => '#microsoft.graph.'.$policy->policy_type,
|
||||
};
|
||||
|
||||
$payload = [
|
||||
'id' => $policy->external_id,
|
||||
'displayName' => $policy->display_name,
|
||||
'@odata.type' => $odataType,
|
||||
'createdDateTime' => $policy->created_at?->toIso8601String(),
|
||||
'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($policy->platform) {
|
||||
$payload['platform'] = $policy->platform;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'source' => 'metadata_only',
|
||||
'original_failure' => $failureReason,
|
||||
'original_status' => $status,
|
||||
'warnings' => [
|
||||
sprintf(
|
||||
'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.',
|
||||
$status ?? 'error'
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
'warnings' => $metadata['warnings'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,19 @@ public function __construct(
|
||||
* @return array<int> IDs of policies synced or created
|
||||
*/
|
||||
public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array
|
||||
{
|
||||
$result = $this->syncPoliciesWithReport($tenant, $supportedTypes);
|
||||
|
||||
return $result['synced'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync supported policies for a tenant from Microsoft Graph.
|
||||
*
|
||||
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}>|null $supportedTypes
|
||||
* @return array{synced: array<int>, failures: array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>}
|
||||
*/
|
||||
public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array
|
||||
{
|
||||
if (! $tenant->isActive()) {
|
||||
throw new \RuntimeException('Tenant is archived or inactive.');
|
||||
@ -32,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
|
||||
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
||||
$synced = [];
|
||||
$failures = [];
|
||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||
|
||||
foreach ($types as $typeConfig) {
|
||||
@ -69,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$failures[] = [
|
||||
'policy_type' => $policyType,
|
||||
'status' => $response->status,
|
||||
'errors' => $response->errors,
|
||||
'meta' => $response->meta,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -109,6 +130,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$this->reclassifyConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$policy = Policy::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@ -128,11 +155,18 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
}
|
||||
}
|
||||
|
||||
return $synced;
|
||||
return [
|
||||
'synced' => $synced,
|
||||
'failures' => $failures,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveCanonicalPolicyType(string $policyType, array $policyData): string
|
||||
{
|
||||
if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
|
||||
return $this->resolveConfigurationPolicyType($policyData);
|
||||
}
|
||||
|
||||
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
|
||||
return $policyType;
|
||||
}
|
||||
@ -141,11 +175,75 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat
|
||||
return 'windowsEnrollmentStatusPage';
|
||||
}
|
||||
|
||||
if ($this->isEnrollmentRestrictionItem($policyData)) {
|
||||
return 'enrollmentRestriction';
|
||||
return 'enrollmentRestriction';
|
||||
}
|
||||
|
||||
private function resolveConfigurationPolicyType(array $policyData): string
|
||||
{
|
||||
if ($this->isSecurityBaselineConfigurationPolicy($policyData)) {
|
||||
return 'securityBaselinePolicy';
|
||||
}
|
||||
|
||||
return $policyType;
|
||||
if ($this->isEndpointSecurityConfigurationPolicy($policyData)) {
|
||||
return 'endpointSecurityPolicy';
|
||||
}
|
||||
|
||||
return 'settingsCatalogPolicy';
|
||||
}
|
||||
|
||||
private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
|
||||
{
|
||||
$technologies = $policyData['technologies'] ?? null;
|
||||
|
||||
if (is_string($technologies)) {
|
||||
if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($technologies)) {
|
||||
foreach ($technologies as $technology) {
|
||||
if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$templateReference = $policyData['templateReference'] ?? null;
|
||||
|
||||
if (! is_array($templateReference)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($templateReference as $value) {
|
||||
if (is_string($value) && stripos($value, 'endpoint') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
|
||||
{
|
||||
$templateReference = $policyData['templateReference'] ?? null;
|
||||
|
||||
if (! is_array($templateReference)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$templateFamily = $templateReference['templateFamily'] ?? null;
|
||||
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($templateReference as $value) {
|
||||
if (is_string($value) && stripos($value, 'baseline') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isEnrollmentStatusPageItem(array $policyData): bool
|
||||
@ -157,33 +255,6 @@ private function isEnrollmentStatusPageItem(array $policyData): bool
|
||||
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
|
||||
}
|
||||
|
||||
private function isEnrollmentRestrictionItem(array $policyData): bool
|
||||
{
|
||||
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
|
||||
|
||||
$restrictionOdataTypes = [
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
|
||||
];
|
||||
|
||||
if (is_string($odataType)) {
|
||||
foreach ($restrictionOdataTypes as $expected) {
|
||||
if (strcasecmp($odataType, $expected) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return is_string($configurationType)
|
||||
&& in_array($configurationType, [
|
||||
'deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||
'deviceEnrollmentLimitConfiguration',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
{
|
||||
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
|
||||
@ -231,6 +302,53 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
{
|
||||
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
||||
|
||||
if (! in_array($policyType, $configurationTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCorrect = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->first();
|
||||
|
||||
if ($existingCorrect) {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->first();
|
||||
|
||||
if (! $existingWrong) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong->forceFill([
|
||||
'policy_type' => $policyType,
|
||||
])->save();
|
||||
|
||||
PolicyVersion::query()
|
||||
->where('policy_id', $existingWrong->id)
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single policy from Graph and update local metadata.
|
||||
*/
|
||||
|
||||
@ -38,6 +38,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
|
||||
$results = [];
|
||||
|
||||
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
||||
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||
@ -228,6 +229,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect snapshots that were captured as metadata-only.
|
||||
*
|
||||
* These snapshots cannot be safely restored because they do not contain the
|
||||
* complete settings payload.
|
||||
*
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
|
||||
{
|
||||
$affected = [];
|
||||
$hasRestoreEnabled = false;
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
if (! $this->isMetadataOnlySnapshot($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||
if ($restoreMode !== 'preview-only') {
|
||||
$hasRestoreEnabled = true;
|
||||
}
|
||||
|
||||
$affected[] = [
|
||||
'backup_item_id' => $item->id,
|
||||
'policy_identifier' => $item->policy_identifier,
|
||||
'policy_type' => $item->policy_type,
|
||||
'label' => $item->resolvedDisplayName(),
|
||||
'restore_mode' => $restoreMode,
|
||||
];
|
||||
}
|
||||
|
||||
if ($affected === []) {
|
||||
return [
|
||||
'code' => 'metadata_only',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Snapshot completeness',
|
||||
'message' => 'No metadata-only snapshots detected.',
|
||||
'meta' => [
|
||||
'count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
|
||||
$message = $hasRestoreEnabled
|
||||
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
|
||||
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
|
||||
|
||||
return [
|
||||
'code' => 'metadata_only',
|
||||
'severity' => $severity,
|
||||
'title' => 'Snapshot completeness',
|
||||
'message' => $message,
|
||||
'meta' => [
|
||||
'count' => count($affected),
|
||||
'items' => $this->truncateList($affected, 10),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function isMetadataOnlySnapshot(BackupItem $item): bool
|
||||
{
|
||||
$metadata = is_array($item->metadata) ? $item->metadata : [];
|
||||
|
||||
$source = $metadata['source'] ?? null;
|
||||
$snapshotSource = $metadata['snapshot_source'] ?? null;
|
||||
|
||||
if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$warnings = $metadata['warnings'] ?? null;
|
||||
if (is_array($warnings)) {
|
||||
foreach ($warnings as $warning) {
|
||||
if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
|
||||
@ -151,6 +151,18 @@ public function executeFromPolicyVersion(
|
||||
'version_captured_at' => $version->captured_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
$versionMetadata = is_array($version->metadata) ? $version->metadata : [];
|
||||
$snapshotSource = $versionMetadata['source'] ?? null;
|
||||
|
||||
if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') {
|
||||
$backupItemMetadata['snapshot_source'] = $snapshotSource;
|
||||
}
|
||||
|
||||
$snapshotWarnings = $versionMetadata['warnings'] ?? null;
|
||||
if (is_array($snapshotWarnings) && $snapshotWarnings !== []) {
|
||||
$backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== '')));
|
||||
}
|
||||
|
||||
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
||||
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||
}
|
||||
|
||||
@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string
|
||||
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
|
||||
$cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId);
|
||||
|
||||
// Remove other template placeholders, e.g. "{FirewallRuleId}"
|
||||
$cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned);
|
||||
|
||||
// Clean up consecutive underscores
|
||||
$cleaned = preg_replace('/_+/', '_', $cleaned);
|
||||
$cleaned = trim($cleaned, '_');
|
||||
|
||||
$lowered = Str::lower($cleaned);
|
||||
|
||||
if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) {
|
||||
$suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_');
|
||||
|
||||
if ($suffix === '') {
|
||||
return 'Firewall rule';
|
||||
}
|
||||
|
||||
$known = [
|
||||
'displayname' => 'Name',
|
||||
'name' => 'Name',
|
||||
'description' => 'Description',
|
||||
'direction' => 'Direction',
|
||||
'action' => 'Action',
|
||||
'actiontype' => 'Action type',
|
||||
'profiles' => 'Profiles',
|
||||
'profile' => 'Profile',
|
||||
'protocol' => 'Protocol',
|
||||
'localport' => 'Local port',
|
||||
'remoteport' => 'Remote port',
|
||||
'localaddress' => 'Local address',
|
||||
'remoteaddress' => 'Remote address',
|
||||
'interfacetype' => 'Interface type',
|
||||
'interfacetypes' => 'Interface types',
|
||||
'edgetraversal' => 'Edge traversal',
|
||||
'enabled' => 'Enabled',
|
||||
];
|
||||
|
||||
if (isset($known[$suffix])) {
|
||||
return $known[$suffix];
|
||||
}
|
||||
|
||||
return Str::headline($suffix);
|
||||
}
|
||||
|
||||
// Convert to title case
|
||||
$prettified = Str::title(str_replace('_', ' ', $cleaned));
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ public function __construct(
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return $policyType === 'settingsCatalogPolicy';
|
||||
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -85,6 +85,8 @@ public function captureFromGraph(
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
|
||||
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
|
||||
$assignments = null;
|
||||
$scopeTags = null;
|
||||
$assignmentMetadata = [];
|
||||
@ -141,11 +143,17 @@ public function captureFromGraph(
|
||||
}
|
||||
|
||||
$metadata = array_merge(
|
||||
['source' => 'version_capture'],
|
||||
$snapshotMetadata,
|
||||
['capture_source' => 'version_capture'],
|
||||
$metadata,
|
||||
$assignmentMetadata
|
||||
$assignmentMetadata,
|
||||
);
|
||||
|
||||
if ($snapshotWarnings !== []) {
|
||||
$existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
|
||||
$metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
|
||||
}
|
||||
|
||||
return $this->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
],
|
||||
'settingsCatalogPolicy' => [
|
||||
'resource' => 'deviceManagement/configurationPolicies',
|
||||
'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'],
|
||||
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'],
|
||||
'allowed_expand' => ['settings'],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
@ -132,6 +132,76 @@
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'endpointSecurityPolicy' => [
|
||||
'resource' => 'deviceManagement/configurationPolicies',
|
||||
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'member_hydration_strategy' => 'subresource_settings',
|
||||
'subresources' => [
|
||||
'settings' => [
|
||||
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
|
||||
'collection' => true,
|
||||
'paging' => true,
|
||||
'allowed_select' => [],
|
||||
'allowed_expand' => [],
|
||||
],
|
||||
],
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_delete_method' => 'DELETE',
|
||||
|
||||
// Scope Tags
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'securityBaselinePolicy' => [
|
||||
'resource' => 'deviceManagement/configurationPolicies',
|
||||
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'member_hydration_strategy' => 'subresource_settings',
|
||||
'subresources' => [
|
||||
'settings' => [
|
||||
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
|
||||
'collection' => true,
|
||||
'paging' => true,
|
||||
'allowed_select' => [],
|
||||
'allowed_expand' => [],
|
||||
],
|
||||
],
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_delete_method' => 'DELETE',
|
||||
|
||||
// Scope Tags
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'windowsUpdateRing' => [
|
||||
'resource' => 'deviceManagement/deviceConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
@ -263,6 +333,43 @@
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'assignments',
|
||||
],
|
||||
'mamAppConfiguration' => [
|
||||
'resource' => 'deviceAppManagement/targetedManagedAppConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.targetedManagedAppConfiguration',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'assignments',
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'managedDeviceAppConfiguration' => [
|
||||
'resource' => 'deviceAppManagement/mobileAppConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => [
|
||||
'#microsoft.graph.managedDeviceMobileAppConfiguration',
|
||||
'#microsoft.graph.mobileAppConfiguration',
|
||||
],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_payload_key' => 'assignments',
|
||||
'supports_scope_tags' => true,
|
||||
'scope_tag_field' => 'roleScopeTagIds',
|
||||
],
|
||||
'conditionalAccessPolicy' => [
|
||||
'resource' => 'identity/conditionalAccess/policies',
|
||||
'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'],
|
||||
|
||||
@ -84,6 +84,27 @@
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium-high',
|
||||
],
|
||||
[
|
||||
'type' => 'mamAppConfiguration',
|
||||
'label' => 'App Configuration (MAM)',
|
||||
'category' => 'Apps/MAM',
|
||||
'platform' => 'mobile',
|
||||
'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations',
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium-high',
|
||||
],
|
||||
[
|
||||
'type' => 'managedDeviceAppConfiguration',
|
||||
'label' => 'App Configuration (Device)',
|
||||
'category' => 'Apps/MAM',
|
||||
'platform' => 'mobile',
|
||||
'endpoint' => 'deviceAppManagement/mobileAppConfigurations',
|
||||
'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium-high',
|
||||
],
|
||||
[
|
||||
'type' => 'conditionalAccessPolicy',
|
||||
'label' => 'Conditional Access',
|
||||
@ -140,7 +161,6 @@
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
@ -165,6 +185,26 @@
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'endpointSecurityPolicy',
|
||||
'label' => 'Endpoint Security Policies',
|
||||
'category' => 'Endpoint Security',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/configurationPolicies',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'securityBaselinePolicy',
|
||||
'label' => 'Security Baselines',
|
||||
'category' => 'Endpoint Security',
|
||||
'platform' => 'windows',
|
||||
'endpoint' => 'deviceManagement/configurationPolicies',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
],
|
||||
[
|
||||
'type' => 'mobileApp',
|
||||
'label' => 'Applications (Metadata only)',
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
@php
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$general = $getState();
|
||||
$entries = is_array($general) ? ($general['entries'] ?? []) : [];
|
||||
$cards = [];
|
||||
@ -61,6 +64,27 @@
|
||||
'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200',
|
||||
'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200',
|
||||
];
|
||||
|
||||
$formatIsoDateTime = static function (string $value): ?string {
|
||||
$trimmed = trim($value);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Graph can return 7 fractional digits; PHP supports 6 (microseconds).
|
||||
$normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed);
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse($normalized)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if (empty($cards))
|
||||
@ -72,6 +96,9 @@
|
||||
$keyLower = $entry['key_lower'] ?? '';
|
||||
$value = $entry['value'] ?? null;
|
||||
$isPlatform = str_contains($keyLower, 'platform');
|
||||
$isTechnologies = str_contains($keyLower, 'technolog');
|
||||
$isTemplateReference = str_contains($keyLower, 'template');
|
||||
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
|
||||
$toneKey = match (true) {
|
||||
str_contains($keyLower, 'name') => 'name',
|
||||
str_contains($keyLower, 'platform') => 'platform',
|
||||
@ -88,6 +115,15 @@
|
||||
$isBooleanValue = is_bool($value);
|
||||
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
|
||||
$isNumericValue = is_numeric($value);
|
||||
|
||||
$badgeItems = null;
|
||||
|
||||
if ($isListValue) {
|
||||
$badgeItems = $value;
|
||||
} elseif (($isPlatform || $isTechnologies) && is_string($value)) {
|
||||
$split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== ''));
|
||||
$badgeItems = $split !== [] ? $split : [$value];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="tp-policy-general-card group relative overflow-hidden rounded-xl border border-gray-200/70 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-gray-300/70 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900 dark:hover:border-gray-600">
|
||||
@ -100,16 +136,50 @@
|
||||
{{ $entry['key'] ?? '-' }}
|
||||
</dt>
|
||||
<dd class="mt-2 text-left">
|
||||
@if ($isListValue)
|
||||
@if ($isTemplateReference && is_array($value))
|
||||
@php
|
||||
$templateDisplayName = $value['templateDisplayName'] ?? null;
|
||||
$templateFamily = $value['templateFamily'] ?? null;
|
||||
$templateDisplayVersion = $value['templateDisplayVersion'] ?? null;
|
||||
$templateId = $value['templateId'] ?? null;
|
||||
|
||||
$familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($familyLabel)
|
||||
<x-filament::badge color="gray" size="sm">{{ $familyLabel }}</x-filament::badge>
|
||||
@endif
|
||||
@if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '')
|
||||
<x-filament::badge color="gray" size="sm">{{ $templateDisplayVersion }}</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (is_string($templateId) && $templateId !== '')
|
||||
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 break-all">
|
||||
{{ $templateId }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($isDateTime)
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">
|
||||
{{ $formattedDateTime }}
|
||||
</div>
|
||||
@elseif (is_array($badgeItems) && $badgeItems !== [])
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($value as $item)
|
||||
@foreach ($badgeItems as $item)
|
||||
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
|
||||
{{ $item }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($isJsonValue)
|
||||
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre>
|
||||
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
@elseif ($isBooleanValue || $isBooleanString)
|
||||
@php
|
||||
$boolValue = $isBooleanValue
|
||||
@ -126,7 +196,7 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words text-left">
|
||||
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }}
|
||||
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
|
||||
</div>
|
||||
@endif
|
||||
</dd>
|
||||
|
||||
@ -59,6 +59,24 @@
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
$asEnabledDisabledBadgeValue = function (mixed $value): ?bool {
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($value));
|
||||
|
||||
return match ($normalized) {
|
||||
'enabled', 'true', 'yes', '1' => true,
|
||||
'disabled', 'false', 'no', '0' => false,
|
||||
default => null,
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -98,9 +116,13 @@
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
@if(is_bool($row['value']))
|
||||
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
|
||||
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
|
||||
@ -135,16 +157,20 @@
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($block['rows'] ?? [] as $row)
|
||||
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
|
||||
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
|
||||
@if(!empty($row['description']))
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
|
||||
@endif
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
@if(is_bool($row['value']))
|
||||
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm">
|
||||
{{ $row['value'] ? 'Enabled' : 'Disabled' }}
|
||||
@php
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
|
||||
@endphp
|
||||
|
||||
@if(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
</x-filament::badge>
|
||||
@elseif(is_numeric($row['value']))
|
||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@ -192,6 +218,8 @@
|
||||
|
||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||
|
||||
$badgeValue = $asEnabledDisabledBadgeValue($rawValue);
|
||||
@endphp
|
||||
|
||||
@if($isScriptContent)
|
||||
@ -276,6 +304,10 @@
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif(! is_null($badgeValue))
|
||||
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
|
||||
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
# Requirements Checklist (017)
|
||||
|
||||
- [x] Type keys and Graph resources confirmed for App Config Policies.
|
||||
- [x] Type keys and Graph resources confirmed for Endpoint Security Policies.
|
||||
- [x] Type keys and Graph resources confirmed for Security Baselines.
|
||||
- [x] Restore mode decisions documented (enabled vs preview-only) per type.
|
||||
- [x] Tests planned for sync + backup + preview.
|
||||
@ -0,0 +1,41 @@
|
||||
# Plan: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017)
|
||||
|
||||
**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines`
|
||||
**Date**: 2026-01-02
|
||||
**Input**: [spec.md](./spec.md)
|
||||
|
||||
## Approach
|
||||
1. Inventory current supported types (config + graph contracts) and identify gaps.
|
||||
2. Define new type keys and metadata in `config/tenantpilot.php`.
|
||||
3. Add graph contracts in `config/graph_contracts.php` (resource, assigns, scope tags, create/update methods).
|
||||
4. Extend snapshot/capture and restore services as needed (special casing only when required).
|
||||
5. Add tests for: sync listing + backup capture + restore preview entry.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Type keys + Graph resources
|
||||
- `mamAppConfiguration` (MAM App Config)
|
||||
- Graph collection: `deviceAppManagement/targetedManagedAppConfigurations`
|
||||
- Primary `@odata.type`: `#microsoft.graph.targetedManagedAppConfiguration`
|
||||
- `endpointSecurityPolicy` (Endpoint Security Policies)
|
||||
- Graph collection: `deviceManagement/configurationPolicies`
|
||||
- Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy`
|
||||
- Classification: configuration policies where the snapshot indicates Endpoint Security via `technologies` and/or `templateReference`.
|
||||
- `securityBaselinePolicy` (Security Baselines)
|
||||
- Graph collection: `deviceManagement/configurationPolicies`
|
||||
- Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy`
|
||||
- Classification: configuration policies where the snapshot indicates a baseline via `templateReference` (template family/type).
|
||||
|
||||
### Restore modes
|
||||
- `mamAppConfiguration`: `enabled` (risk: medium-high)
|
||||
- `endpointSecurityPolicy`: `preview-only` (risk: high)
|
||||
- `securityBaselinePolicy`: `preview-only` (risk: high)
|
||||
|
||||
### Test plan
|
||||
- Sync: new types show up with correct labels and do not leak into `settingsCatalogPolicy` / `appProtectionPolicy`.
|
||||
- Backup: items created and snapshots captured for each new type.
|
||||
- Restore: at minimum, restore preview produces entries; execution remains blocked for preview-only types.
|
||||
|
||||
## Notes
|
||||
- Default restore mode for security-sensitive types should be conservative (preview-only) unless we already have safe restore semantics.
|
||||
- Prefer using existing generic graph-contract-driven code paths.
|
||||
@ -0,0 +1,47 @@
|
||||
# Feature Specification: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017)
|
||||
|
||||
**Feature Branch**: `feat/017-policy-types-mam-endpoint-security-baselines`
|
||||
**Created**: 2026-01-02
|
||||
**Status**: Draft
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — MAM App Config backup & restore (Priority: P1)
|
||||
As an admin, I want Managed App Configuration policies (App Config) to be inventoried, backed up, and restorable, so I can safely manage MAM configurations (Outlook, Teams, Edge, OneDrive, etc.) at scale.
|
||||
|
||||
This includes both:
|
||||
- App configuration (app-targeted) via `deviceAppManagement/targetedManagedAppConfigurations`
|
||||
- App configuration (managed device) via `deviceAppManagement/mobileAppConfigurations`
|
||||
|
||||
**Acceptance Scenarios**
|
||||
1. Given a tenant with App Config policies, when I sync policies, then I can see them in the policy inventory with correct type labels.
|
||||
2. Given a policy, when I add it to a backup set, then it is captured and a backup item is created.
|
||||
3. Given a backup item, when I start a restore preview, then I can see a safe preview of changes.
|
||||
|
||||
### User Story 2 — Endpoint Security policies (not only intents) (Priority: P1)
|
||||
As an admin, I want Endpoint Security policies (Firewall/Defender/ASR/BitLocker etc.) supported, so the Windows security core can be backed up and restored.
|
||||
|
||||
**Acceptance Scenarios**
|
||||
1. Given Endpoint Security policies exist, sync shows them as their own policy type.
|
||||
2. Backup captures them successfully.
|
||||
|
||||
### User Story 3 — Security baselines (Priority: P1)
|
||||
As an admin, I want Security Baselines supported because they are commonly used and are expected in a complete solution.
|
||||
|
||||
**Acceptance Scenarios**
|
||||
1. Given baseline policies exist, sync shows them.
|
||||
2. Backup captures them.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- **FR-001**: Add support for Managed App Configuration policies.
|
||||
- **FR-002**: Add support for Endpoint Security policies beyond intents.
|
||||
- **FR-003**: Add support for Security Baselines.
|
||||
- **FR-004**: Each new type must integrate with: inventory, backup, restore preview, and (where safe) restore execution.
|
||||
- **FR-005**: Changes must be covered by automated tests.
|
||||
|
||||
## Success Criteria
|
||||
- **SC-001**: New policy types appear in inventory & picker.
|
||||
- **SC-002**: Backup/restore preview works for new types.
|
||||
- **SC-003**: No regressions in existing policy flows.
|
||||
@ -0,0 +1,56 @@
|
||||
# Tasks: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017)
|
||||
|
||||
**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines`
|
||||
**Date**: 2026-01-02
|
||||
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||
|
||||
## Phase 1: Setup
|
||||
- [x] T001 Create spec/plan/tasks and checklist.
|
||||
|
||||
## Phase 2: Inventory & Design
|
||||
- [x] T002 Inventory existing policy types and identify missing graph resources.
|
||||
- [x] T003 Decide type keys + restore modes for: app config, endpoint security policies, security baselines.
|
||||
|
||||
## Phase 3: Tests (TDD)
|
||||
- [x] T004 Add tests for policy sync listing new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`).
|
||||
- [x] T005 Add tests for backup capture creating backup items for new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`).
|
||||
- [x] T006 Add tests for restore preview for new types (at least preview-only for `endpointSecurityPolicy`, `securityBaselinePolicy`).
|
||||
|
||||
## Phase 4: Implementation
|
||||
- [x] T007 Add new types to `config/tenantpilot.php`.
|
||||
- [x] T008 Add new graph contracts to `config/graph_contracts.php`.
|
||||
- [x] T009 Implement any required snapshot/capture/restore handling.
|
||||
|
||||
## Phase 4b: Follow-up (MAM Device App Config)
|
||||
- [x] T012 Add managed device app configurations (`mobileAppConfigurations`) to supported types + graph contracts + sync test.
|
||||
|
||||
## Phase 5: Verification
|
||||
- [x] T010 Run targeted tests.
|
||||
- [x] T011 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
|
||||
## Phase 5b: UI Polish
|
||||
- [x] T013 Render Enabled/Disabled-like string values as badges in settings views for consistent UI.
|
||||
|
||||
## Phase 4c: Bugfix
|
||||
- [x] T014 Ensure configuration policy list sync selects `technologies`/`templateReference` so Endpoint Security + Baselines can be classified.
|
||||
|
||||
## Phase 4d: UX Debuggability
|
||||
- [x] T015 Show per-type sync failures in Policy sync UI so 0-synced cases are actionable.
|
||||
|
||||
## Phase 4e: Bugfix (Graph OData)
|
||||
- [x] T016 Fix configuration policy list sync `$select` to avoid unsupported `version` field (Graph 400).
|
||||
|
||||
## Phase 4f: Bugfix (Enrollment OData)
|
||||
- [x] T017 Fix ESP (`windowsEnrollmentStatusPage`) sync filter to avoid Graph 400 "Invalid filter PropertyName".
|
||||
|
||||
## Phase 4g: Bugfix (Endpoint Security Classification)
|
||||
- [x] T018 Fix endpoint security configuration policies being misclassified as settings catalog when `technologies=mdm`.
|
||||
|
||||
## Phase 4h: Bugfix (Graph Pagination)
|
||||
- [x] T019 Paginate Graph list responses so Endpoint Security policies on page 2+ are synced.
|
||||
|
||||
## Phase 4i: Feature (Endpoint Security Settings Display)
|
||||
- [x] T020 Hydrate `configurationPolicies/{id}/settings` for `endpointSecurityPolicy` + `securityBaselinePolicy` snapshots.
|
||||
- [x] T021 Render Endpoint Security + Baselines via Settings Catalog normalizer/table (diff + UI).
|
||||
- [x] T022 Prettify Endpoint Security template settings (use `templateReference.templateDisplayName` as fallback category + nicer Firewall rule labels/values).
|
||||
- [x] T023 Improve Policy General tab cards (template reference summary, badges, readable timestamps).
|
||||
@ -53,6 +53,105 @@
|
||||
])
|
||||
->callTableBulkAction('add_selected_to_backup_set', $policies)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added');
|
||||
expect(collect($notifications)->last()['status'] ?? null)->toBe('success');
|
||||
});
|
||||
|
||||
test('policy picker table does not warn if failures already existed but did not increase', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test backup',
|
||||
'status' => 'partial',
|
||||
'metadata' => [
|
||||
'failures' => [
|
||||
['policy_id' => 1, 'reason' => 'Previous failure', 'status' => 500],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$policies = Policy::factory()->count(1)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'ignored_at' => null,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) {
|
||||
$mock->shouldReceive('addPoliciesToSet')
|
||||
->once()
|
||||
->andReturn($backupSet);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetPolicyPickerTable::class, [
|
||||
'backupSetId' => $backupSet->id,
|
||||
])
|
||||
->callTableBulkAction('add_selected_to_backup_set', $policies)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added');
|
||||
expect(collect($notifications)->last()['status'] ?? null)->toBe('success');
|
||||
});
|
||||
|
||||
test('policy picker table warns when new failures were added', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test backup',
|
||||
'status' => 'completed',
|
||||
'metadata' => ['failures' => []],
|
||||
]);
|
||||
|
||||
$policies = Policy::factory()->count(1)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'ignored_at' => null,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) {
|
||||
$mock->shouldReceive('addPoliciesToSet')
|
||||
->once()
|
||||
->andReturnUsing(function () use ($backupSet) {
|
||||
$backupSet->update([
|
||||
'status' => 'partial',
|
||||
'metadata' => [
|
||||
'failures' => [
|
||||
['policy_id' => 123, 'reason' => 'New failure', 'status' => 500],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return $backupSet->refresh();
|
||||
});
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetPolicyPickerTable::class, [
|
||||
'backupSetId' => $backupSet->id,
|
||||
])
|
||||
->callTableBulkAction('add_selected_to_backup_set', $policies)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added with failures');
|
||||
expect(collect($notifications)->last()['status'] ?? null)->toBe('warning');
|
||||
});
|
||||
|
||||
test('policy picker table can filter by has versions', function () {
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('settings catalog policies render a normalized settings table', function () {
|
||||
test('configuration policy types render a normalized settings table', function (string $policyType) {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'local-tenant',
|
||||
'name' => 'Tenant One',
|
||||
@ -24,7 +24,7 @@
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'scp-policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'policy_type' => $policyType,
|
||||
'display_name' => 'Settings Catalog Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
@ -33,7 +33,7 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'policy_type' => $policyType,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
@ -116,4 +116,8 @@
|
||||
preg_match('/<section[^>]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection);
|
||||
expect($versionGeneralSection)->not->toBeEmpty();
|
||||
expect($versionGeneralSection[0])->toContain('x-cloak');
|
||||
});
|
||||
})->with([
|
||||
'settingsCatalogPolicy',
|
||||
'endpointSecurityPolicy',
|
||||
'securityBaselinePolicy',
|
||||
]);
|
||||
|
||||
41
tests/Feature/PolicyGeneralViewTest.php
Normal file
41
tests/Feature/PolicyGeneralViewTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
it('renders template reference, badges, and readable timestamps in policy general cards', function () {
|
||||
$html = View::file(
|
||||
resource_path('views/filament/infolists/entries/policy-general.blade.php'),
|
||||
[
|
||||
'getState' => fn (): array => [
|
||||
'entries' => [
|
||||
['key' => 'Name', 'value' => 'WindowsFirewall Endpointsecurity'],
|
||||
['key' => 'Platforms', 'value' => 'windows10'],
|
||||
['key' => 'Technologies', 'value' => 'mdm,microsoftSense'],
|
||||
['key' => 'Template Reference', 'value' => [
|
||||
'templateId' => '19c8aa67-f286-4861-9aa0-f23541d31680_1',
|
||||
'templateFamily' => 'endpointSecurityFirewall',
|
||||
'templateDisplayName' => 'Windows Firewall Rules',
|
||||
'templateDisplayVersion' => 'Version 1',
|
||||
]],
|
||||
['key' => 'Last Modified', 'value' => '2026-01-03T00:52:32.2784312Z'],
|
||||
],
|
||||
],
|
||||
],
|
||||
)->render();
|
||||
|
||||
expect($html)->toContain('Windows Firewall Rules');
|
||||
expect($html)->toContain('Endpoint Security Firewall');
|
||||
expect($html)->toContain('Version 1');
|
||||
expect($html)->toContain('19c8aa67-f286-4861-9aa0-f23541d31680_1');
|
||||
|
||||
expect($html)->toContain('mdm');
|
||||
expect($html)->toContain('microsoftSense');
|
||||
expect($html)->toContain('fi-badge');
|
||||
|
||||
expect($html)->toContain('2026-01-03 00:52:32');
|
||||
expect($html)->not->toContain('T00:52:32.2784312Z');
|
||||
|
||||
expect($html)->not->toContain('"templateId"');
|
||||
});
|
||||
30
tests/Feature/PolicySettingsStandardViewTest.php
Normal file
30
tests/Feature/PolicySettingsStandardViewTest.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
it('renders Enabled/Disabled strings as badges', function () {
|
||||
$html = View::file(
|
||||
resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'),
|
||||
[
|
||||
'getState' => fn (): array => [
|
||||
'settings' => [
|
||||
[
|
||||
'type' => 'table',
|
||||
'title' => 'App configuration settings',
|
||||
'rows' => [
|
||||
['label' => 'StringEnabled', 'value' => 'Enabled'],
|
||||
['label' => 'StringDisabled', 'value' => 'Disabled'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'policy_type' => 'managedDeviceAppConfiguration',
|
||||
],
|
||||
],
|
||||
)->render();
|
||||
|
||||
expect($html)->toContain('Enabled')
|
||||
->and($html)->toContain('Disabled')
|
||||
->and($html)->toContain('fi-badge');
|
||||
});
|
||||
@ -71,3 +71,77 @@
|
||||
|
||||
expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||
});
|
||||
|
||||
test('policy sync classifies ESP items without relying on Graph isof filter', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-sync-esp-no-filter',
|
||||
'name' => 'Tenant Sync ESP No Filter',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
|
||||
$payload = [
|
||||
[
|
||||
'id' => 'esp-1',
|
||||
'displayName' => 'Enrollment Status Page',
|
||||
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
|
||||
],
|
||||
[
|
||||
'id' => 'restriction-1',
|
||||
'displayName' => 'Default Enrollment Restriction',
|
||||
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
],
|
||||
[
|
||||
'id' => 'other-1',
|
||||
'displayName' => 'Other Enrollment Config',
|
||||
'@odata.type' => '#microsoft.graph.someOtherEnrollmentConfiguration',
|
||||
'deviceEnrollmentConfigurationType' => 'someOtherEnrollmentConfiguration',
|
||||
],
|
||||
];
|
||||
|
||||
$mock->shouldReceive('listPolicies')
|
||||
->andReturnUsing(function (string $policyType) use ($payload) {
|
||||
if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
|
||||
return new GraphResponse(true, $payload);
|
||||
}
|
||||
|
||||
return new GraphResponse(true, []);
|
||||
});
|
||||
});
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$service->syncPolicies($tenant, [
|
||||
[
|
||||
'type' => 'windowsEnrollmentStatusPage',
|
||||
'platform' => 'all',
|
||||
'filter' => null,
|
||||
],
|
||||
[
|
||||
'type' => 'enrollmentRestriction',
|
||||
'platform' => 'all',
|
||||
'filter' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$espIds = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', 'windowsEnrollmentStatusPage')
|
||||
->pluck('external_id')
|
||||
->all();
|
||||
|
||||
$restrictionIds = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', 'enrollmentRestriction')
|
||||
->orderBy('external_id')
|
||||
->pluck('external_id')
|
||||
->all();
|
||||
|
||||
expect($espIds)->toMatchArray(['esp-1']);
|
||||
expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']);
|
||||
});
|
||||
|
||||
61
tests/Feature/PolicySyncServiceReportTest.php
Normal file
61
tests/Feature/PolicySyncServiceReportTest.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
it('returns a report with failures when policy list calls fail', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
|
||||
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->andReturnUsing(function (string $policyType) {
|
||||
return match ($policyType) {
|
||||
'endpointSecurityPolicy' => new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 403,
|
||||
errors: [['message' => 'Forbidden']],
|
||||
meta: ['path' => '/deviceManagement/configurationPolicies'],
|
||||
),
|
||||
default => new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
['id' => 'scp-1', 'displayName' => 'Settings Catalog', 'technologies' => ['mdm']],
|
||||
],
|
||||
status: 200,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$result = $service->syncPoliciesWithReport($tenant, [
|
||||
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
|
||||
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
expect($result)->toHaveKeys(['synced', 'failures']);
|
||||
expect($result['synced'])->toBeArray();
|
||||
expect($result['failures'])->toBeArray();
|
||||
|
||||
expect(count($result['synced']))->toBe(1);
|
||||
expect(Policy::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
|
||||
expect(count($result['failures']))->toBe(1);
|
||||
expect($result['failures'][0]['policy_type'])->toBe('endpointSecurityPolicy');
|
||||
expect($result['failures'][0]['status'])->toBe(403);
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
@ -75,3 +76,221 @@
|
||||
expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null)
|
||||
->toBe('deviceManagement/windowsQualityUpdateProfiles');
|
||||
});
|
||||
|
||||
it('includes managed device app configurations in supported types', function () {
|
||||
$supported = config('tenantpilot.supported_policy_types');
|
||||
$byType = collect($supported)->keyBy('type');
|
||||
|
||||
expect($byType)->toHaveKey('managedDeviceAppConfiguration');
|
||||
expect($byType['managedDeviceAppConfiguration']['endpoint'] ?? null)
|
||||
->toBe('deviceAppManagement/mobileAppConfigurations');
|
||||
expect($byType['managedDeviceAppConfiguration']['filter'] ?? null)
|
||||
->toBe("microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false");
|
||||
});
|
||||
|
||||
it('syncs managed device app configurations from Graph', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
|
||||
$logger->shouldReceive('logRequest')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$logger->shouldReceive('logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->with('managedDeviceAppConfiguration', mockery::type('array'))
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'madc-1',
|
||||
'displayName' => 'MAM Device Config',
|
||||
'@odata.type' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$service->syncPolicies($tenant, [
|
||||
['type' => 'managedDeviceAppConfiguration', 'platform' => 'mobile'],
|
||||
]);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'managedDeviceAppConfiguration')->count())
|
||||
->toBe(1);
|
||||
});
|
||||
|
||||
it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
|
||||
$logger->shouldReceive('logRequest')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$logger->shouldReceive('logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$graphResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'scp-1',
|
||||
'name' => 'Settings Catalog Alpha',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['mdm'],
|
||||
'templateReference' => null,
|
||||
],
|
||||
[
|
||||
'id' => 'esp-1',
|
||||
'name' => 'Endpoint Security Beta',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => 'mdm',
|
||||
'templateReference' => [
|
||||
'templateFamily' => 'endpointSecurityDiskEncryption',
|
||||
'templateDisplayName' => 'BitLocker',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'sb-1',
|
||||
'name' => 'Security Baseline Gamma',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['mdm'],
|
||||
'templateReference' => [
|
||||
'templateFamily' => 'securityBaseline',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$calledTypes = [];
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->times(3)
|
||||
->andReturnUsing(function (string $policyType) use (&$calledTypes, $graphResponse) {
|
||||
$calledTypes[] = $policyType;
|
||||
|
||||
return $graphResponse;
|
||||
});
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$service->syncPolicies($tenant, [
|
||||
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
|
||||
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
|
||||
['type' => 'securityBaselinePolicy', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
expect($calledTypes)->toMatchArray([
|
||||
'settingsCatalogPolicy',
|
||||
'endpointSecurityPolicy',
|
||||
'securityBaselinePolicy',
|
||||
]);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'settingsCatalogPolicy')->count())
|
||||
->toBe(1);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'endpointSecurityPolicy')->count())
|
||||
->toBe(1);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'securityBaselinePolicy')->count())
|
||||
->toBe(1);
|
||||
});
|
||||
|
||||
it('reclassifies configuration policies when canonical type changes', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'esp-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Misclassified',
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
|
||||
$logger->shouldReceive('logRequest')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$logger->shouldReceive('logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
$graphResponse = new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'esp-1',
|
||||
'name' => 'Endpoint Security Beta',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => 'mdm',
|
||||
'templateReference' => [
|
||||
'templateFamily' => 'endpointSecurityDiskEncryption',
|
||||
'templateDisplayName' => 'BitLocker',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->times(3)
|
||||
->andReturn($graphResponse);
|
||||
|
||||
$service = app(PolicySyncService::class);
|
||||
|
||||
$service->syncPolicies($tenant, [
|
||||
['type' => 'settingsCatalogPolicy', 'platform' => 'windows'],
|
||||
['type' => 'endpointSecurityPolicy', 'platform' => 'windows'],
|
||||
['type' => 'securityBaselinePolicy', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
expect(Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->whereNull('ignored_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'endpointSecurityPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'settingsCatalogPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->count())->toBe(0);
|
||||
|
||||
$version->refresh();
|
||||
|
||||
expect($version->policy_type)->toBe('endpointSecurityPolicy');
|
||||
});
|
||||
|
||||
267
tests/Feature/PolicyTypes017Test.php
Normal file
267
tests/Feature/PolicyTypes017Test.php
Normal file
@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class PolicyTypes017GraphClient implements GraphClientInterface
|
||||
{
|
||||
/** @var array<int, array{method:string,policyType?:string,policyId?:string,path?:string,options:array<string,mixed>}> */
|
||||
public array $requests = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'listPolicies', 'policyType' => $policyType, 'options' => $options];
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options];
|
||||
|
||||
$payload = match ($policyType) {
|
||||
'mamAppConfiguration' => [
|
||||
'id' => $policyId,
|
||||
'displayName' => 'MAM App Config',
|
||||
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'endpointSecurityPolicy' => [
|
||||
'id' => $policyId,
|
||||
'name' => 'Endpoint Security Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['endpointSecurity'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'securityBaselinePolicy' => [
|
||||
'id' => $policyId,
|
||||
'name' => 'Security Baseline Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'templateReference' => ['templateFamily' => 'securityBaseline'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
default => [
|
||||
'id' => $policyId,
|
||||
'name' => 'Settings Catalog Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['mdm'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
};
|
||||
|
||||
return new GraphResponse(success: true, data: ['payload' => $payload]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'getOrganization', 'options' => $options];
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options];
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'getServicePrincipalPermissions', 'options' => $options];
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['method' => 'request', 'path' => $path, 'options' => $options];
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
}
|
||||
|
||||
it('creates backup items for the new 017 policy types', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$mam = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'mam-1',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'platform' => 'mobile',
|
||||
]);
|
||||
|
||||
$esp = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'esp-1',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$sb = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'sb-1',
|
||||
'policy_type' => 'securityBaselinePolicy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($tenant) {
|
||||
$mock->shouldReceive('capture')
|
||||
->times(3)
|
||||
->andReturnUsing(function (Policy $policy) use ($tenant) {
|
||||
$snapshot = match ($policy->policy_type) {
|
||||
'mamAppConfiguration' => [
|
||||
'id' => $policy->external_id,
|
||||
'displayName' => 'MAM App Config',
|
||||
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'endpointSecurityPolicy' => [
|
||||
'id' => $policy->external_id,
|
||||
'name' => 'Endpoint Security Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['endpointSecurity'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'securityBaselinePolicy' => [
|
||||
'id' => $policy->external_id,
|
||||
'name' => 'Security Baseline Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'templateReference' => ['templateFamily' => 'securityBaseline'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
default => [
|
||||
'id' => $policy->external_id,
|
||||
'name' => 'Settings Catalog Policy',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['mdm'],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
};
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'snapshot' => $snapshot,
|
||||
'assignments' => null,
|
||||
'scope_tags' => null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $snapshot,
|
||||
'assignments' => null,
|
||||
'scope_tags' => null,
|
||||
'metadata' => [],
|
||||
'warnings' => [],
|
||||
],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
$service = app(BackupService::class);
|
||||
$backupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [$mam->id, $esp->id, $sb->id],
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
name: '017 backup',
|
||||
includeAssignments: false,
|
||||
includeScopeTags: false,
|
||||
includeFoundations: false,
|
||||
);
|
||||
|
||||
expect($backupSet->items)->toHaveCount(3);
|
||||
|
||||
$types = $backupSet->items->pluck('policy_type')->all();
|
||||
sort($types);
|
||||
|
||||
expect($types)->toBe([
|
||||
'endpointSecurityPolicy',
|
||||
'mamAppConfiguration',
|
||||
'securityBaselinePolicy',
|
||||
]);
|
||||
|
||||
expect(BackupItem::query()->where('backup_set_id', $backupSet->id)->count())
|
||||
->toBe(3);
|
||||
});
|
||||
|
||||
it('uses configured restore modes in preview for the new 017 policy types', function () {
|
||||
$this->mock(GraphClientInterface::class);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'status' => 'completed',
|
||||
'item_count' => 3,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'mam-1',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'platform' => 'mobile',
|
||||
'payload' => [
|
||||
'id' => 'mam-1',
|
||||
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
],
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'esp-1',
|
||||
'policy_type' => 'endpointSecurityPolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'esp-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'technologies' => ['endpointSecurity'],
|
||||
],
|
||||
]);
|
||||
|
||||
BackupItem::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'sb-1',
|
||||
'policy_type' => 'securityBaselinePolicy',
|
||||
'platform' => 'windows',
|
||||
'payload' => [
|
||||
'id' => 'sb-1',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'templateReference' => ['templateFamily' => 'securityBaseline'],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$preview = $service->preview($tenant, $backupSet);
|
||||
|
||||
$byType = collect($preview)->keyBy('policy_type');
|
||||
|
||||
expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled');
|
||||
expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only');
|
||||
expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only');
|
||||
});
|
||||
@ -220,3 +220,76 @@
|
||||
expect($skippedGroups)->toBeArray();
|
||||
expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1');
|
||||
});
|
||||
|
||||
test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'display_name' => 'MAM App Config',
|
||||
'platform' => 'mobile',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
|
||||
'assignments' => [],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'warnings' => [
|
||||
'Graph returned 500 for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->andReturn([]);
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->fillForm([
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->callFormComponentAction('check_results', 'run_restore_checks');
|
||||
|
||||
$summary = $component->get('data.check_summary');
|
||||
$results = $component->get('data.check_results');
|
||||
|
||||
expect($summary['blocking'] ?? null)->toBe(1);
|
||||
expect($summary['has_blockers'] ?? null)->toBeTrue();
|
||||
|
||||
$metadataOnly = collect($results)->firstWhere('code', 'metadata_only');
|
||||
expect($metadataOnly)->toBeArray();
|
||||
expect($metadataOnly['severity'] ?? null)->toBe('blocking');
|
||||
});
|
||||
|
||||
66
tests/Feature/VersionCaptureMetadataOnlyTest.php
Normal file
66
tests/Feature/VersionCaptureMetadataOnlyTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use App\Services\Intune\VersionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('persists metadata-only snapshot metadata on captured versions', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'platform' => 'mobile',
|
||||
'external_id' => 'A_meta_only',
|
||||
'display_name' => 'MAM Config Meta',
|
||||
]);
|
||||
|
||||
$this->mock(PolicySnapshotService::class, function ($mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'payload' => [
|
||||
'id' => 'A_meta_only',
|
||||
'displayName' => 'MAM Config Meta',
|
||||
'@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration',
|
||||
],
|
||||
'metadata' => [
|
||||
'source' => 'metadata_only',
|
||||
'original_status' => 500,
|
||||
'original_failure' => 'InternalServerError: upstream',
|
||||
],
|
||||
'warnings' => [
|
||||
'Snapshot captured from local metadata only (Graph API returned 500).',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$this->mock(AssignmentFetcher::class, function ($mock) {
|
||||
$mock->shouldReceive('fetch')->never();
|
||||
});
|
||||
|
||||
$this->mock(ScopeTagResolver::class, function ($mock) {
|
||||
$mock->shouldReceive('resolve')->never();
|
||||
});
|
||||
|
||||
$service = app(VersionService::class);
|
||||
|
||||
$version = $service->captureFromGraph(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
createdBy: 'tester@example.test',
|
||||
includeAssignments: false,
|
||||
includeScopeTags: false,
|
||||
);
|
||||
|
||||
expect($version->metadata['source'])->toBe('metadata_only');
|
||||
expect($version->metadata['original_status'])->toBe(500);
|
||||
expect($version->metadata['original_failure'])->toContain('InternalServerError');
|
||||
expect($version->metadata['capture_source'])->toBe('version_capture');
|
||||
expect($version->metadata['warnings'])->toBeArray();
|
||||
expect($version->metadata['warnings'][0])->toContain('metadata only');
|
||||
});
|
||||
63
tests/Unit/GraphClientEndpointResolutionTest.php
Normal file
63
tests/Unit/GraphClientEndpointResolutionTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('graph.base_url', 'https://graph.microsoft.com');
|
||||
config()->set('graph.version', 'beta');
|
||||
config()->set('graph.tenant_id', 'tenant');
|
||||
config()->set('graph.client_id', 'client');
|
||||
config()->set('graph.client_secret', 'secret');
|
||||
config()->set('graph.scope', 'https://graph.microsoft.com/.default');
|
||||
|
||||
// Ensure we don't accidentally resolve via supported_policy_types
|
||||
config()->set('tenantpilot.supported_policy_types', []);
|
||||
});
|
||||
|
||||
it('uses graph contract resource path for applyPolicy', function () {
|
||||
config()->set('graph_contracts.types.mamAppConfiguration', [
|
||||
'resource' => 'deviceAppManagement/targetedManagedAppConfigurations',
|
||||
'allowed_select' => ['id', 'displayName'],
|
||||
'allowed_expand' => [],
|
||||
'type_family' => ['#microsoft.graph.targetedManagedAppConfiguration'],
|
||||
'create_method' => 'POST',
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'hydration' => 'properties',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://login.microsoftonline.com/*' => Http::response([
|
||||
'access_token' => 'fake-token',
|
||||
'expires_in' => 3600,
|
||||
], 200),
|
||||
'https://graph.microsoft.com/*' => Http::response(['id' => 'A_1'], 200),
|
||||
]);
|
||||
|
||||
$client = new MicrosoftGraphClient(
|
||||
logger: app(GraphLogger::class),
|
||||
contracts: app(GraphContractRegistry::class),
|
||||
);
|
||||
|
||||
$client->applyPolicy(
|
||||
policyType: 'mamAppConfiguration',
|
||||
policyId: 'A_1',
|
||||
payload: ['displayName' => 'Test'],
|
||||
options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'],
|
||||
);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
if (! str_contains($request->url(), 'graph.microsoft.com')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1');
|
||||
});
|
||||
});
|
||||
45
tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php
Normal file
45
tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
test('managed device app configuration normalizer shows app config keys and values', function () {
|
||||
$normalizer = app(PolicyNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'id' => 'policy-1',
|
||||
'displayName' => 'MAMDevice',
|
||||
'@odata.type' => '#microsoft.graph.iosMobileAppConfiguration',
|
||||
'settings' => [
|
||||
[
|
||||
'appConfigKey' => 'com.microsoft.outlook.EmailProfile.AccountType',
|
||||
'appConfigKeyType' => 'stringType',
|
||||
'appConfigKeyValue' => 'ModernAuth',
|
||||
],
|
||||
[
|
||||
'appConfigKey' => 'com.microsoft.outlook.Mail.FocusedInbox',
|
||||
'appConfigKeyType' => 'booleanType',
|
||||
'appConfigKeyValue' => 'true',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$normalized = $normalizer->normalize($snapshot, 'managedDeviceAppConfiguration', 'mobile');
|
||||
|
||||
$blocks = collect($normalized['settings'] ?? []);
|
||||
|
||||
$appConfig = $blocks->firstWhere('title', 'App configuration settings');
|
||||
expect($appConfig)->not->toBeNull();
|
||||
expect($appConfig['type'] ?? null)->toBe('table');
|
||||
|
||||
$rows = collect($appConfig['rows'] ?? []);
|
||||
$row = $rows->firstWhere('label', 'com.microsoft.outlook.EmailProfile.AccountType');
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row['value'] ?? null)->toBe('ModernAuth');
|
||||
|
||||
$boolRow = $rows->firstWhere('label', 'com.microsoft.outlook.Mail.FocusedInbox');
|
||||
expect($boolRow)->not->toBeNull();
|
||||
expect($boolRow['value'] ?? null)->toBeTrue();
|
||||
});
|
||||
160
tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php
Normal file
160
tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('includes contract select fields when listing policies', function () {
|
||||
Http::fake([
|
||||
'graph.microsoft.com/*' => Http::response(['value' => []], 200),
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
|
||||
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
|
||||
|
||||
$client = new MicrosoftGraphClient(
|
||||
logger: $logger,
|
||||
contracts: app(GraphContractRegistry::class),
|
||||
);
|
||||
|
||||
$client->listPolicies('endpointSecurityPolicy', [
|
||||
'access_token' => 'test-token',
|
||||
]);
|
||||
|
||||
$client->listPolicies('securityBaselinePolicy', [
|
||||
'access_token' => 'test-token',
|
||||
]);
|
||||
|
||||
$client->listPolicies('settingsCatalogPolicy', [
|
||||
'access_token' => 'test-token',
|
||||
]);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
$url = $request->url();
|
||||
|
||||
if (! str_contains($url, '/deviceManagement/configurationPolicies')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
|
||||
expect($query)->toHaveKey('$select');
|
||||
|
||||
$select = (string) $query['$select'];
|
||||
|
||||
expect($select)->toContain('technologies')
|
||||
->and($select)->toContain('templateReference')
|
||||
->and($select)->toContain('name')
|
||||
->and($select)->not->toContain('@odata.type');
|
||||
|
||||
expect($select)->not->toContain('displayName');
|
||||
expect($select)->not->toContain('version');
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('retries list policies without $select on select/expand parsing errors', function () {
|
||||
Http::fake([
|
||||
'graph.microsoft.com/*' => Http::sequence()
|
||||
->push([
|
||||
'error' => [
|
||||
'code' => 'BadRequest',
|
||||
'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.",
|
||||
],
|
||||
], 400)
|
||||
->push([
|
||||
'error' => [
|
||||
'code' => 'BadRequest',
|
||||
'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.",
|
||||
],
|
||||
], 400)
|
||||
->push(['value' => [['id' => 'policy-1', 'name' => 'Policy One']]], 200),
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
|
||||
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
|
||||
|
||||
$client = new MicrosoftGraphClient(
|
||||
logger: $logger,
|
||||
contracts: app(GraphContractRegistry::class),
|
||||
);
|
||||
|
||||
$response = $client->listPolicies('settingsCatalogPolicy', [
|
||||
'access_token' => 'test-token',
|
||||
]);
|
||||
|
||||
expect($response->successful())->toBeTrue();
|
||||
expect($response->data)->toHaveCount(1);
|
||||
expect($response->warnings)->toContain('Capability fallback applied: removed $select for compatibility.');
|
||||
|
||||
$recorded = Http::recorded();
|
||||
|
||||
expect($recorded)->toHaveCount(3);
|
||||
|
||||
[$firstRequest] = $recorded[0];
|
||||
[$secondRequest] = $recorded[1];
|
||||
[$thirdRequest] = $recorded[2];
|
||||
|
||||
parse_str((string) parse_url($firstRequest->url(), PHP_URL_QUERY), $firstQuery);
|
||||
parse_str((string) parse_url($secondRequest->url(), PHP_URL_QUERY), $secondQuery);
|
||||
parse_str((string) parse_url($thirdRequest->url(), PHP_URL_QUERY), $thirdQuery);
|
||||
|
||||
expect($firstQuery)->toHaveKey('$select');
|
||||
expect($secondQuery)->toHaveKey('$select');
|
||||
expect($thirdQuery)->not->toHaveKey('$select');
|
||||
});
|
||||
|
||||
it('paginates list policies when nextLink is present', function () {
|
||||
$nextLink = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$skiptoken=page2';
|
||||
|
||||
Http::fake([
|
||||
'graph.microsoft.com/*' => Http::sequence()
|
||||
->push([
|
||||
'value' => [
|
||||
['id' => 'policy-1', 'name' => 'Policy One'],
|
||||
],
|
||||
'@odata.nextLink' => $nextLink,
|
||||
], 200)
|
||||
->push([
|
||||
'value' => [
|
||||
['id' => 'policy-2', 'name' => 'Policy Two'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$logger = mock(GraphLogger::class);
|
||||
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
|
||||
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
|
||||
|
||||
$client = new MicrosoftGraphClient(
|
||||
logger: $logger,
|
||||
contracts: app(GraphContractRegistry::class),
|
||||
);
|
||||
|
||||
$response = $client->listPolicies('settingsCatalogPolicy', [
|
||||
'access_token' => 'test-token',
|
||||
]);
|
||||
|
||||
expect($response->successful())->toBeTrue();
|
||||
expect($response->data)->toHaveCount(2);
|
||||
expect(collect($response->data)->pluck('id')->all())->toMatchArray(['policy-1', 'policy-2']);
|
||||
|
||||
$recorded = Http::recorded();
|
||||
|
||||
expect($recorded)->toHaveCount(2);
|
||||
|
||||
[$firstRequest] = $recorded[0];
|
||||
[$secondRequest] = $recorded[1];
|
||||
|
||||
expect($firstRequest->url())->toContain('/deviceManagement/configurationPolicies');
|
||||
expect($secondRequest->url())->toBe($nextLink);
|
||||
});
|
||||
64
tests/Unit/PolicyCaptureOrchestratorTest.php
Normal file
64
tests/Unit/PolicyCaptureOrchestratorTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use App\Services\Intune\VersionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('capture returns failure when snapshot fetch fails (no exception)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'app_client_id' => 'client-1',
|
||||
'app_client_secret' => 'secret-1',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'external_id' => 'A_f38e7f58-ac7c-455d-bb0e-f56bf1b3890e',
|
||||
'display_name' => 'MAM Example',
|
||||
'platform' => 'mobile',
|
||||
]);
|
||||
|
||||
$snapshotService = Mockery::mock(PolicySnapshotService::class);
|
||||
$snapshotService
|
||||
->shouldReceive('fetch')
|
||||
->once()
|
||||
->andReturn([
|
||||
'failure' => [
|
||||
'reason' => 'InternalServerError: upstream',
|
||||
'status' => 500,
|
||||
],
|
||||
]);
|
||||
|
||||
$orchestrator = new PolicyCaptureOrchestrator(
|
||||
versionService: Mockery::mock(VersionService::class),
|
||||
snapshotService: $snapshotService,
|
||||
assignmentFetcher: Mockery::mock(AssignmentFetcher::class),
|
||||
groupResolver: Mockery::mock(GroupResolver::class),
|
||||
assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class),
|
||||
scopeTagResolver: Mockery::mock(ScopeTagResolver::class),
|
||||
);
|
||||
|
||||
$result = $orchestrator->capture(
|
||||
policy: $policy,
|
||||
tenant: $tenant,
|
||||
includeAssignments: true,
|
||||
includeScopeTags: true,
|
||||
createdBy: 'admin@example.test',
|
||||
);
|
||||
|
||||
expect($result)->toHaveKey('failure');
|
||||
expect($result['failure']['status'])->toBe(500);
|
||||
expect($result['failure']['reason'])->toContain('InternalServerError');
|
||||
});
|
||||
@ -89,6 +89,68 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigurationPolicySettingsSnapshotGraphClient implements GraphClientInterface
|
||||
{
|
||||
public array $requests = [];
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = ['getPolicy', $policyType, $policyId, $options];
|
||||
|
||||
return new GraphResponse(success: true, data: [
|
||||
'payload' => [
|
||||
'id' => $policyId,
|
||||
'name' => 'Endpoint Security Alpha',
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requests[] = [$method, $path, $options];
|
||||
|
||||
if ($method === 'GET' && str_contains($path, 'deviceManagement/configurationPolicies/') && str_ends_with($path, '/settings')) {
|
||||
return new GraphResponse(success: true, data: [
|
||||
'value' => [
|
||||
[
|
||||
'id' => 'setting-1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha',
|
||||
'simpleSettingValue' => [
|
||||
'value' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
}
|
||||
|
||||
it('hydrates compliance policy scheduled actions into snapshots', function () {
|
||||
$client = new PolicySnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
@ -125,6 +187,45 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)');
|
||||
});
|
||||
|
||||
it('hydrates configuration policy settings into snapshots', function (string $policyType) {
|
||||
$client = new ConfigurationPolicySettingsSnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-endpoint-security',
|
||||
'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' => 'esp-123',
|
||||
'policy_type' => $policyType,
|
||||
'display_name' => 'Endpoint Security Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('payload');
|
||||
expect($result['payload'])->toHaveKey('settings');
|
||||
expect($result['payload']['settings'])->toHaveCount(1);
|
||||
expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete');
|
||||
|
||||
$paths = collect($client->requests)
|
||||
->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET')
|
||||
->map(fn (array $entry): string => (string) ($entry[1] ?? ''))
|
||||
->values();
|
||||
|
||||
expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue();
|
||||
})->with([
|
||||
'endpointSecurityPolicy',
|
||||
'securityBaselinePolicy',
|
||||
]);
|
||||
|
||||
it('filters mobile app snapshots to metadata-only keys', function () {
|
||||
$client = new PolicySnapshotGraphClient;
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
@ -170,6 +271,123 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requests[0][3]['select'])->not->toContain('@odata.type');
|
||||
});
|
||||
|
||||
test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () {
|
||||
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
|
||||
$client->shouldReceive('getPolicy')
|
||||
->once()
|
||||
->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500));
|
||||
|
||||
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-mam-fallback',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'A_fallback-policy',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'display_name' => 'MAM Config Alpha',
|
||||
'platform' => 'iOS',
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('payload');
|
||||
expect($result)->toHaveKey('metadata');
|
||||
expect($result)->toHaveKey('warnings');
|
||||
expect($result['payload']['id'])->toBe('A_fallback-policy');
|
||||
expect($result['payload']['displayName'])->toBe('MAM Config Alpha');
|
||||
expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration');
|
||||
expect($result['payload']['platform'])->toBe('iOS');
|
||||
expect($result['metadata']['source'])->toBe('metadata_only');
|
||||
expect($result['metadata']['original_status'])->toBe(500);
|
||||
expect($result['warnings'])->toHaveCount(1);
|
||||
expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only');
|
||||
expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible');
|
||||
});
|
||||
|
||||
test('does not fallback to metadata for non-5xx errors', function () {
|
||||
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
|
||||
$client->shouldReceive('getPolicy')
|
||||
->once()
|
||||
->andThrow(new \App\Services\Graph\GraphException('NotFound', 404));
|
||||
|
||||
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-404',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'A_missing',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'display_name' => 'Missing Policy',
|
||||
'platform' => 'iOS',
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('failure');
|
||||
expect($result['failure']['status'])->toBe(404);
|
||||
expect($result['failure']['reason'])->toContain('NotFound');
|
||||
});
|
||||
|
||||
test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () {
|
||||
$client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class);
|
||||
$client->shouldReceive('getPolicy')
|
||||
->once()
|
||||
->andReturn(new \App\Services\Graph\GraphResponse(
|
||||
success: false,
|
||||
data: [
|
||||
'error' => [
|
||||
'code' => 'InternalServerError',
|
||||
'message' => 'Upstream MAM failure',
|
||||
],
|
||||
],
|
||||
status: 500,
|
||||
errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']],
|
||||
meta: [
|
||||
'client_request_id' => 'client-req-1',
|
||||
'request_id' => 'req-1',
|
||||
],
|
||||
));
|
||||
|
||||
app()->instance(\App\Services\Graph\GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-mam-fallback-response',
|
||||
'app_client_id' => 'client-123',
|
||||
'app_client_secret' => 'secret-123',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'A_resp_fallback',
|
||||
'policy_type' => 'mamAppConfiguration',
|
||||
'display_name' => 'MAM Config Response',
|
||||
'platform' => 'iOS',
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('payload');
|
||||
expect($result['metadata']['source'])->toBe('metadata_only');
|
||||
expect($result['metadata']['original_status'])->toBe(500);
|
||||
expect($result['metadata']['original_failure'])->toContain('InternalServerError');
|
||||
});
|
||||
|
||||
class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface
|
||||
{
|
||||
public array $requests = [];
|
||||
@ -251,3 +469,78 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14);
|
||||
expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete');
|
||||
});
|
||||
|
||||
class FailedSnapshotGraphClient implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(
|
||||
success: false,
|
||||
data: [],
|
||||
status: 500,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
meta: [
|
||||
'error_code' => 'InternalServerError',
|
||||
'error_message' => 'An internal server error has occurred',
|
||||
'request_id' => 'req-123',
|
||||
'client_request_id' => 'client-456',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(success: true, data: []);
|
||||
}
|
||||
}
|
||||
|
||||
it('returns actionable reasons when graph snapshot fails', function () {
|
||||
app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-failure',
|
||||
'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' => 'mam-123',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Compliance Config',
|
||||
'platform' => 'mobile',
|
||||
]);
|
||||
|
||||
$service = app(PolicySnapshotService::class);
|
||||
$result = $service->fetch($tenant, $policy);
|
||||
|
||||
expect($result)->toHaveKey('failure');
|
||||
expect($result['failure']['status'])->toBe(500);
|
||||
expect($result['failure']['reason'])->toContain('InternalServerError');
|
||||
expect($result['failure']['reason'])->toContain('An internal server error has occurred');
|
||||
expect($result['failure']['reason'])->toContain('client_request_id=client-456');
|
||||
expect($result['failure']['reason'])->toContain('request_id=req-123');
|
||||
});
|
||||
|
||||
@ -31,3 +31,139 @@
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
|
||||
});
|
||||
|
||||
it('builds a settings table for endpoint security configuration policies', function (string $policyType) {
|
||||
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 's1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
||||
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
|
||||
'simpleSettingValue' => [
|
||||
'value' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$normalized = $normalizer->normalize($snapshot, $policyType, 'windows');
|
||||
|
||||
$rows = $normalized['settings_table']['rows'] ?? [];
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
|
||||
})->with([
|
||||
'endpointSecurityPolicy',
|
||||
'securityBaselinePolicy',
|
||||
]);
|
||||
|
||||
it('prettifies endpoint security firewall rules settings for display', function () {
|
||||
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
|
||||
|
||||
$groupDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}';
|
||||
$nameDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_displayname';
|
||||
$directionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_direction';
|
||||
$actionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_action';
|
||||
$interfaceTypesDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_interfacetypes';
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'templateReference' => [
|
||||
'templateFamily' => 'endpointSecurityFirewall',
|
||||
'templateDisplayName' => 'Windows Firewall Rules',
|
||||
'templateDisplayVersion' => 'Version 1',
|
||||
],
|
||||
'settings' => [
|
||||
[
|
||||
'id' => 'rule-1',
|
||||
'settingInstance' => [
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance',
|
||||
'settingDefinitionId' => $groupDefinitionId,
|
||||
'groupSettingCollectionValue' => [
|
||||
[
|
||||
'children' => [
|
||||
[
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
|
||||
'settingDefinitionId' => $nameDefinitionId,
|
||||
'simpleSettingValue' => [
|
||||
'value' => 'Test0',
|
||||
],
|
||||
],
|
||||
[
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
|
||||
'settingDefinitionId' => $directionDefinitionId,
|
||||
'choiceSettingValue' => [
|
||||
'value' => "{$directionDefinitionId}_in",
|
||||
],
|
||||
],
|
||||
[
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
|
||||
'settingDefinitionId' => $actionDefinitionId,
|
||||
'choiceSettingValue' => [
|
||||
'value' => "{$actionDefinitionId}_allow",
|
||||
],
|
||||
],
|
||||
[
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance',
|
||||
'settingDefinitionId' => $interfaceTypesDefinitionId,
|
||||
'choiceSettingCollectionValue' => [
|
||||
[
|
||||
'value' => "{$interfaceTypesDefinitionId}_lan",
|
||||
'children' => [],
|
||||
],
|
||||
[
|
||||
'value' => "{$interfaceTypesDefinitionId}_remoteaccess",
|
||||
'children' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$normalized = $normalizer->normalize($snapshot, 'endpointSecurityPolicy', 'windows');
|
||||
$rows = collect($normalized['settings_table']['rows'] ?? []);
|
||||
|
||||
$groupRow = $rows->firstWhere('definition_id', $groupDefinitionId);
|
||||
expect($groupRow)->not->toBeNull();
|
||||
expect($groupRow['category'] ?? null)->toBe('Windows Firewall Rules');
|
||||
expect($groupRow['definition'] ?? null)->toBe('Firewall rule');
|
||||
expect($groupRow['data_type'] ?? null)->toBe('Group');
|
||||
expect($groupRow['value'] ?? null)->toBe('(group)');
|
||||
|
||||
$nameRow = $rows->firstWhere('definition_id', $nameDefinitionId);
|
||||
expect($nameRow)->not->toBeNull();
|
||||
expect($nameRow['category'] ?? null)->toBe('Windows Firewall Rules');
|
||||
expect($nameRow['definition'] ?? null)->toBe('Name');
|
||||
expect($nameRow['value'] ?? null)->toBe('Test0');
|
||||
|
||||
$directionRow = $rows->firstWhere('definition_id', $directionDefinitionId);
|
||||
expect($directionRow)->not->toBeNull();
|
||||
expect($directionRow['category'] ?? null)->toBe('Windows Firewall Rules');
|
||||
expect($directionRow['definition'] ?? null)->toBe('Direction');
|
||||
expect($directionRow['data_type'] ?? null)->toBe('Choice');
|
||||
expect($directionRow['value'] ?? null)->toBe('Inbound');
|
||||
|
||||
$actionRow = $rows->firstWhere('definition_id', $actionDefinitionId);
|
||||
expect($actionRow)->not->toBeNull();
|
||||
expect($actionRow['category'] ?? null)->toBe('Windows Firewall Rules');
|
||||
expect($actionRow['definition'] ?? null)->toBe('Action');
|
||||
expect($actionRow['data_type'] ?? null)->toBe('Choice');
|
||||
expect($actionRow['value'] ?? null)->toBe('Allow');
|
||||
|
||||
$interfaceTypesRow = $rows->firstWhere('definition_id', $interfaceTypesDefinitionId);
|
||||
expect($interfaceTypesRow)->not->toBeNull();
|
||||
expect($interfaceTypesRow['category'] ?? null)->toBe('Windows Firewall Rules');
|
||||
expect($interfaceTypesRow['definition'] ?? null)->toBe('Interface types');
|
||||
expect($interfaceTypesRow['data_type'] ?? null)->toBe('Choice');
|
||||
expect($interfaceTypesRow['value'] ?? null)->toBe('LAN, Remote access');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user