feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
This commit is contained in:
parent
a8bdfc5a77
commit
412dd7ad66
@ -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