feat(010): Administrative Templates – restore from PolicyVersion + version visibility (#13)
Problem: Restore nutzt bisher den Snapshot aus dem BackupSet (BackupItem). Wenn der Snapshot “unvollständig”/nicht der gewünschte Stand ist, landen nach Restore nur wenige Admin-Template-Settings in Intune. Lösung: Neue Action “Restore to Intune” direkt an einer konkreten PolicyVersion (inkl. Dry-Run Toggle) → reproduzierbarer Rollback auf exakt diese Version. Restore-UI zeigt jetzt PolicyVersion-Nummer (version: X) in der Item-Auswahl + BackupSet Items Tabelle hat eine Version-Spalte. Implementierung: RestoreService::executeFromPolicyVersion() erzeugt dafür einen kleinen, temporären BackupSet+BackupItem aus der Version und startet einen normalen RestoreRun. Pest-Test: PolicyVersionRestoreToIntuneTest.php Specs/TODO: Offene Follow-ups sind dokumentiert in tasks.md unter “Open TODOs (Follow-up)”. QA (GUI): Inventory → Policies → <Policy> → Versions → Restore to Intune (erst Dry-Run, dann Execute) Backups & Restore → Restore Runs → Create (bei Items steht version: X) Backups & Restore → Backup Sets → <Set> (Version-Spalte) Tests: PolicyVersionRestoreToIntuneTest.php Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #13
This commit is contained in:
parent
fbb9748725
commit
61b0b1bc23
@ -30,6 +30,11 @@ public function table(Table $table): Table
|
|||||||
->sortable()
|
->sortable()
|
||||||
->searchable()
|
->searchable()
|
||||||
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
||||||
|
Tables\Columns\TextColumn::make('policyVersion.version_number')
|
||||||
|
->label('Version')
|
||||||
|
->badge()
|
||||||
|
->default('—')
|
||||||
|
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
|
|||||||
@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -24,6 +30,55 @@ public function table(Table $table): Table
|
|||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('restore_to_intune')
|
||||||
|
->label('Restore to Intune')
|
||||||
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||||
|
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
|
->label('Preview only (dry-run)')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $restoreService->executeFromPolicyVersion(
|
||||||
|
tenant: $tenant,
|
||||||
|
version: $record,
|
||||||
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
|
actorEmail: auth()->user()?->email,
|
||||||
|
actorName: auth()->user()?->name,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run failed to start')
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run started')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||||
|
}),
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
|
|||||||
@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
->orWhereDoesntHave('policy')
|
->orWhereDoesntHave('policy')
|
||||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||||
})
|
})
|
||||||
->with('policy:id,display_name')
|
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy(function (BackupItem $item) {
|
->sortBy(function (BackupItem $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$platform = $item->platform ?? $meta['platform'] ?? null;
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||||
$displayName = $item->resolvedDisplayName();
|
$displayName = $item->resolvedDisplayName();
|
||||||
$identifier = $item->policy_identifier ?? null;
|
$identifier = $item->policy_identifier ?? null;
|
||||||
|
$versionNumber = $item->policyVersion?->version_number;
|
||||||
|
|
||||||
$options[$item->id] = $displayName;
|
$options[$item->id] = $displayName;
|
||||||
|
|
||||||
@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$typeLabel,
|
$typeLabel,
|
||||||
$platform,
|
$platform,
|
||||||
"restore: {$restore}",
|
"restore: {$restore}",
|
||||||
|
$versionNumber ? "version: {$versionNumber}" : null,
|
||||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
||||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
use App\Services\Intune\CompliancePolicyNormalizer;
|
||||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||||
|
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ public function register(): void
|
|||||||
AppProtectionPolicyNormalizer::class,
|
AppProtectionPolicyNormalizer::class,
|
||||||
CompliancePolicyNormalizer::class,
|
CompliancePolicyNormalizer::class,
|
||||||
DeviceConfigurationPolicyNormalizer::class,
|
DeviceConfigurationPolicyNormalizer::class,
|
||||||
|
GroupPolicyConfigurationNormalizer::class,
|
||||||
SettingsCatalogPolicyNormalizer::class,
|
SettingsCatalogPolicyNormalizer::class,
|
||||||
],
|
],
|
||||||
'policy-type-normalizers'
|
'policy-type-normalizers'
|
||||||
|
|||||||
@ -108,6 +108,27 @@ public function subresourceSettingsPath(string $policyType, string $policyId): ?
|
|||||||
return str_replace('{id}', urlencode($policyId), $path);
|
return str_replace('{id}', urlencode($policyId), $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function subresourcePath(string $policyType, string $subresourceKey, array $replacements = []): ?string
|
||||||
|
{
|
||||||
|
$subresources = config("graph_contracts.types.$policyType.subresources", []);
|
||||||
|
$subresource = $subresources[$subresourceKey] ?? null;
|
||||||
|
$path = is_array($subresource) ? ($subresource['path'] ?? null) : null;
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($replacements as $key => $value) {
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str_replace($key, urlencode((string) $value), $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
public function settingsWriteMethod(string $policyType): ?string
|
public function settingsWriteMethod(string $policyType): ?string
|
||||||
{
|
{
|
||||||
$contract = $this->get($policyType);
|
$contract = $this->get($policyType);
|
||||||
|
|||||||
181
app/Services/Intune/GroupPolicyConfigurationNormalizer.php
Normal file
181
app/Services/Intune/GroupPolicyConfigurationNormalizer.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class GroupPolicyConfigurationNormalizer implements PolicyTypeNormalizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function supports(string $policyType): bool
|
||||||
|
{
|
||||||
|
return $policyType === 'groupPolicyConfiguration';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 ?? [];
|
||||||
|
$definitionValues = $snapshot['definitionValues'] ?? null;
|
||||||
|
$snapshot = Arr::except($snapshot, ['definitionValues']);
|
||||||
|
|
||||||
|
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
|
||||||
|
|
||||||
|
if (! is_array($definitionValues) || $definitionValues === []) {
|
||||||
|
$normalized['warnings'] = array_values(array_unique(array_merge(
|
||||||
|
$normalized['warnings'] ?? [],
|
||||||
|
['Administrative Template settings not hydrated for this policy.']
|
||||||
|
)));
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($definitionValues as $index => $definitionValue) {
|
||||||
|
if (! is_array($definitionValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $definitionValue['#Definition_displayName'] ?? null;
|
||||||
|
$definitionId = $definitionValue['#Definition_Id'] ?? null;
|
||||||
|
$category = $definitionValue['#Definition_categoryPath'] ?? '-';
|
||||||
|
$enabled = (bool) ($definitionValue['enabled'] ?? false);
|
||||||
|
$path = $this->buildDiffPath(
|
||||||
|
definition: $definition,
|
||||||
|
definitionId: $definitionId,
|
||||||
|
categoryPath: $category,
|
||||||
|
index: $index,
|
||||||
|
);
|
||||||
|
|
||||||
|
$value = $this->formatGroupPolicyValue($definitionValue, $enabled);
|
||||||
|
$dataType = $this->inferGroupPolicyDataType($definitionValue);
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'definition' => is_string($definition) && $definition !== '' ? $definition : 'Definition',
|
||||||
|
'definition_id' => is_string($definitionId) ? $definitionId : null,
|
||||||
|
'category' => is_string($category) && $category !== '' ? $category : '-',
|
||||||
|
'data_type' => $dataType,
|
||||||
|
'value' => $value,
|
||||||
|
'description' => '-',
|
||||||
|
'path' => $path,
|
||||||
|
'raw' => $definitionValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
$normalized['settings_table'] = [
|
||||||
|
'title' => 'Administrative Template settings',
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||||
|
{
|
||||||
|
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
|
||||||
|
|
||||||
|
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inferGroupPolicyDataType(array $definitionValue): string
|
||||||
|
{
|
||||||
|
$presentationValues = $definitionValue['presentationValues'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($presentationValues) || $presentationValues === []) {
|
||||||
|
return 'Boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($presentationValues as $presentationValue) {
|
||||||
|
if (! is_array($presentationValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('values', $presentationValue)) {
|
||||||
|
return 'Choice';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('value', $presentationValue)) {
|
||||||
|
$value = $presentationValue['value'];
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return 'Boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value) || is_numeric($value)) {
|
||||||
|
return 'Number';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Text';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatGroupPolicyValue(array $definitionValue, bool $enabled): string
|
||||||
|
{
|
||||||
|
$presentationValues = $definitionValue['presentationValues'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($presentationValues) || $presentationValues === []) {
|
||||||
|
return $enabled ? 'Enabled' : 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ($presentationValues as $presentationValue) {
|
||||||
|
if (! is_array($presentationValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $presentationValue['#Presentation_Label'] ?? null;
|
||||||
|
$value = $presentationValue['value'] ?? null;
|
||||||
|
$values = $presentationValue['values'] ?? null;
|
||||||
|
|
||||||
|
$valueString = match (true) {
|
||||||
|
is_array($values) => json_encode($values),
|
||||||
|
is_bool($value) => $value ? 'true' : 'false',
|
||||||
|
is_scalar($value) => (string) $value,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($valueString === null) {
|
||||||
|
$clean = Arr::except($presentationValue, ['presentation@odata.bind', '#Presentation_Label', '#Presentation_Id']);
|
||||||
|
$valueString = $clean !== [] ? json_encode($clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($label) && $label !== '') {
|
||||||
|
$parts[] = $label.': '.($valueString ?? '-');
|
||||||
|
} else {
|
||||||
|
$parts[] = $valueString ?? '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' | ', array_values(array_filter($parts, static fn ($part) => $part !== '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDiffPath(mixed $definition, mixed $definitionId, mixed $categoryPath, int $index): string
|
||||||
|
{
|
||||||
|
$label = is_string($definition) && $definition !== '' ? $definition : "definitionValues[{$index}]";
|
||||||
|
|
||||||
|
if (is_string($definitionId) && $definitionId !== '') {
|
||||||
|
$label .= " ({$definitionId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($categoryPath) && $categoryPath !== '' && $categoryPath !== '-') {
|
||||||
|
return $categoryPath.' > '.$label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -87,6 +87,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($policy->policy_type === 'groupPolicyConfiguration') {
|
||||||
|
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
|
||||||
|
tenantIdentifier: $tenantIdentifier,
|
||||||
|
tenant: $tenant,
|
||||||
|
policyId: $policy->external_id,
|
||||||
|
payload: is_array($payload) ? $payload : [],
|
||||||
|
metadata: $metadata
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||||
[$payload, $metadata] = $this->hydrateComplianceActions(
|
[$payload, $metadata] = $this->hydrateComplianceActions(
|
||||||
tenantIdentifier: $tenantIdentifier,
|
tenantIdentifier: $tenantIdentifier,
|
||||||
@ -251,6 +261,176 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant
|
|||||||
return [$payload, $metadata];
|
return [$payload, $metadata];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues.
|
||||||
|
*
|
||||||
|
* @return array{0:array,1:array}
|
||||||
|
*/
|
||||||
|
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
||||||
|
{
|
||||||
|
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
|
||||||
|
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
|
||||||
|
'{id}' => $policyId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) {
|
||||||
|
return [$payload, $metadata];
|
||||||
|
}
|
||||||
|
|
||||||
|
$graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
||||||
|
.'/'.trim((string) config('graph.version', 'beta'), '/');
|
||||||
|
$definitionValues = [];
|
||||||
|
$nextPath = $definitionValuesPath;
|
||||||
|
$hydrationStatus = 'complete';
|
||||||
|
|
||||||
|
while ($nextPath) {
|
||||||
|
$response = $this->graphClient->request('GET', $nextPath, [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'client_id' => $tenant->app_client_id,
|
||||||
|
'client_secret' => $tenant->app_client_secret,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$hydrationStatus = 'failed';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitionValues = array_merge($definitionValues, $response->data['value'] ?? []);
|
||||||
|
$nextLink = $response->data['@odata.nextLink'] ?? null;
|
||||||
|
|
||||||
|
if (! $nextLink) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hydrationStatus === 'failed') {
|
||||||
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
||||||
|
$metadata['warnings'] ?? [],
|
||||||
|
['Hydration failed: could not load Administrative Templates definition values.']
|
||||||
|
)));
|
||||||
|
|
||||||
|
return [$payload, $metadata];
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = [];
|
||||||
|
|
||||||
|
foreach ($definitionValues as $definitionValue) {
|
||||||
|
if (! is_array($definitionValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $definitionValue['definition'] ?? null;
|
||||||
|
$definitionId = is_array($definition) ? ($definition['id'] ?? null) : null;
|
||||||
|
$definitionValueId = $definitionValue['id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($definitionValueId) || $definitionValueId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($definitionId) || $definitionId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [
|
||||||
|
'{id}' => $policyId,
|
||||||
|
'{definitionValueId}' => $definitionValueId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$setting = [
|
||||||
|
'enabled' => (bool) ($definitionValue['enabled'] ?? false),
|
||||||
|
'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')",
|
||||||
|
'#Definition_Id' => $definitionId,
|
||||||
|
'#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null,
|
||||||
|
'#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null,
|
||||||
|
'#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$setting = array_filter($setting, static fn ($value) => $value !== null);
|
||||||
|
|
||||||
|
if (! $presentationValuesPath) {
|
||||||
|
$settings[] = $setting;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationValues = [];
|
||||||
|
$presentationNext = $presentationValuesPath;
|
||||||
|
|
||||||
|
while ($presentationNext) {
|
||||||
|
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'client_id' => $tenant->app_client_id,
|
||||||
|
'client_secret' => $tenant->app_client_secret,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($pvResponse->failed()) {
|
||||||
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
||||||
|
$metadata['warnings'] ?? [],
|
||||||
|
['Hydration warning: could not load some Administrative Templates presentation values.']
|
||||||
|
)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []);
|
||||||
|
$presentationNextLink = $pvResponse->data['@odata.nextLink'] ?? null;
|
||||||
|
|
||||||
|
if (! $presentationNextLink) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationNext = $this->stripGraphBaseUrl((string) $presentationNextLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($presentationValues !== []) {
|
||||||
|
$setting['presentationValues'] = [];
|
||||||
|
|
||||||
|
foreach ($presentationValues as $presentationValue) {
|
||||||
|
if (! is_array($presentationValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentation = $presentationValue['presentation'] ?? null;
|
||||||
|
$presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null;
|
||||||
|
|
||||||
|
if (! is_string($presentationId) || $presentationId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanPresentationValue = Arr::except($presentationValue, [
|
||||||
|
'presentation',
|
||||||
|
'id',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
'createdDateTime',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')";
|
||||||
|
|
||||||
|
$label = is_array($presentation) ? ($presentation['label'] ?? null) : null;
|
||||||
|
|
||||||
|
if (is_string($label) && $label !== '') {
|
||||||
|
$cleanPresentationValue['#Presentation_Label'] = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanPresentationValue['#Presentation_Id'] = $presentationId;
|
||||||
|
|
||||||
|
$setting['presentationValues'][] = $cleanPresentationValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setting['presentationValues'] === []) {
|
||||||
|
unset($setting['presentationValues']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings[] = $setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['definitionValues'] = $settings;
|
||||||
|
|
||||||
|
return [$payload, $metadata];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate compliance policies with scheduled actions (notification templates).
|
* Hydrate compliance policies with scheduled actions (notification templates).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\AssignmentRestoreService;
|
use App\Services\AssignmentRestoreService;
|
||||||
@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
*
|
*
|
||||||
* @param array<int>|null $selectedItemIds
|
* @param array<int>|null $selectedItemIds
|
||||||
*/
|
*/
|
||||||
|
public function executeFromPolicyVersion(
|
||||||
|
Tenant $tenant,
|
||||||
|
PolicyVersion $version,
|
||||||
|
bool $dryRun = true,
|
||||||
|
?string $actorEmail = null,
|
||||||
|
?string $actorName = null,
|
||||||
|
array $groupMapping = [],
|
||||||
|
): RestoreRun {
|
||||||
|
if ($version->tenant_id !== $tenant->id) {
|
||||||
|
throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = $version->policy;
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
throw new \RuntimeException('Policy version has no associated policy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => sprintf(
|
||||||
|
'Policy Version Restore • %s • v%d',
|
||||||
|
$policy->display_name,
|
||||||
|
$version->version_number
|
||||||
|
),
|
||||||
|
'created_by' => $actorEmail,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'policy_version_id' => $version->id,
|
||||||
|
'policy_version_number' => $version->version_number,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopeTags = is_array($version->scope_tags) ? $version->scope_tags : [];
|
||||||
|
$scopeTagIds = $scopeTags['ids'] ?? null;
|
||||||
|
$scopeTagNames = $scopeTags['names'] ?? null;
|
||||||
|
|
||||||
|
$backupItemMetadata = [
|
||||||
|
'source' => 'policy_version',
|
||||||
|
'display_name' => $policy->display_name,
|
||||||
|
'policy_version_id' => $version->id,
|
||||||
|
'policy_version_number' => $version->version_number,
|
||||||
|
'version_captured_at' => $version->captured_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
|
||||||
|
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_version_id' => $version->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
|
||||||
|
'payload' => $version->snapshot ?? [],
|
||||||
|
'metadata' => $backupItemMetadata,
|
||||||
|
'assignments' => $version->assignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$backupItem->id],
|
||||||
|
dryRun: $dryRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function execute(
|
public function execute(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BackupSet $backupSet,
|
BackupSet $backupSet,
|
||||||
@ -151,6 +234,7 @@ public function execute(
|
|||||||
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
||||||
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
||||||
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
||||||
|
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
|
||||||
|
|
||||||
if (! $dryRun) {
|
if (! $dryRun) {
|
||||||
$this->auditFoundationMapping(
|
$this->auditFoundationMapping(
|
||||||
@ -473,6 +557,31 @@ public function execute(
|
|||||||
$assignmentOutcomes = null;
|
$assignmentOutcomes = null;
|
||||||
$assignmentSummary = null;
|
$assignmentSummary = null;
|
||||||
$restoredAssignments = null;
|
$restoredAssignments = null;
|
||||||
|
$definitionValueApply = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
! $dryRun
|
||||||
|
&& $item->policy_type === 'groupPolicyConfiguration'
|
||||||
|
&& is_array($originalPayload)
|
||||||
|
&& is_array($originalPayload['definitionValues'] ?? null)
|
||||||
|
&& $originalPayload['definitionValues'] !== []
|
||||||
|
) {
|
||||||
|
$definitionValueApply = $this->applyGroupPolicyDefinitionValues(
|
||||||
|
tenant: $tenant,
|
||||||
|
tenantIdentifier: $tenantIdentifier,
|
||||||
|
policyId: $createdPolicyId ?? $item->policy_identifier,
|
||||||
|
definitionValues: $originalPayload['definitionValues'],
|
||||||
|
graphOptions: $graphOptions,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
|
||||||
|
$definitionSummary = $definitionValueApply['summary'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($definitionSummary) && ($definitionSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
|
||||||
|
$itemStatus = 'partial';
|
||||||
|
$resultReason = 'Administrative Template settings restored with failures';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
|
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
|
||||||
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
|
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
|
||||||
@ -561,6 +670,11 @@ public function execute(
|
|||||||
$result['assignment_summary'] = $assignmentSummary;
|
$result['assignment_summary'] = $assignmentSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_array($definitionValueApply)) {
|
||||||
|
$result['definition_value_outcomes'] = $definitionValueApply['outcomes'] ?? [];
|
||||||
|
$result['definition_value_summary'] = $definitionValueApply['summary'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if ($complianceActionSummary !== null) {
|
if ($complianceActionSummary !== null) {
|
||||||
$result['compliance_action_summary'] = $complianceActionSummary;
|
$result['compliance_action_summary'] = $complianceActionSummary;
|
||||||
}
|
}
|
||||||
@ -580,6 +694,13 @@ public function execute(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($policy && $itemStatus === 'applied') {
|
if ($policy && $itemStatus === 'applied') {
|
||||||
|
$scopeTagsForVersion = $this->buildScopeTagsForVersion(
|
||||||
|
scopeTagIds: $mappedScopeTagIds ?? null,
|
||||||
|
backupItemMetadata: $item->metadata ?? [],
|
||||||
|
scopeTagMapping: $scopeTagMapping,
|
||||||
|
scopeTagNamesById: $scopeTagNamesById,
|
||||||
|
);
|
||||||
|
|
||||||
$this->versionService->captureVersion(
|
$this->versionService->captureVersion(
|
||||||
policy: $policy,
|
policy: $policy,
|
||||||
payload: $item->payload,
|
payload: $item->payload,
|
||||||
@ -589,7 +710,8 @@ public function execute(
|
|||||||
'restore_run_id' => $restoreRun->id,
|
'restore_run_id' => $restoreRun->id,
|
||||||
'backup_item_id' => $item->id,
|
'backup_item_id' => $item->id,
|
||||||
],
|
],
|
||||||
assignments: $restoredAssignments,
|
assignments: $item->assignments,
|
||||||
|
scopeTags: $scopeTagsForVersion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1960,6 +2082,330 @@ private function stripOdataAndReadOnly(array $payload): array
|
|||||||
return $clean;
|
return $clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrative Templates (groupPolicyConfiguration) restore: wipe existing definitionValues and recreate from snapshot.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $definitionValues
|
||||||
|
* @param array<string, mixed> $graphOptions
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
|
||||||
|
*/
|
||||||
|
private function applyGroupPolicyDefinitionValues(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $tenantIdentifier,
|
||||||
|
string $policyId,
|
||||||
|
array $definitionValues,
|
||||||
|
array $graphOptions,
|
||||||
|
array $context,
|
||||||
|
): array {
|
||||||
|
$outcomes = [];
|
||||||
|
$summary = ['success' => 0, 'failed' => 0, 'skipped' => 0];
|
||||||
|
|
||||||
|
$listPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
|
||||||
|
$createPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_group_policy_definition_values_list', $context + [
|
||||||
|
'method' => 'GET',
|
||||||
|
'endpoint' => $listPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingResponse = $this->graphClient->request('GET', $listPath, $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_group_policy_definition_values_list', $existingResponse, $context + [
|
||||||
|
'method' => 'GET',
|
||||||
|
'endpoint' => $listPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existing = $existingResponse->data['value'] ?? [];
|
||||||
|
|
||||||
|
foreach ($existing as $existingValue) {
|
||||||
|
$existingId = is_array($existingValue) ? ($existingValue['id'] ?? null) : null;
|
||||||
|
|
||||||
|
if (! is_string($existingId) || $existingId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletePath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues/{$existingId}";
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_group_policy_definition_values_delete', $context + [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'endpoint' => $deletePath,
|
||||||
|
'definition_value_id' => $existingId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_group_policy_definition_values_delete', $deleteResponse, $context + [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'endpoint' => $deletePath,
|
||||||
|
'definition_value_id' => $existingId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($definitionValues as $definitionValue) {
|
||||||
|
if (! is_array($definitionValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $definitionValue['#Definition_displayName'] ?? null;
|
||||||
|
$definitionId = $definitionValue['#Definition_Id'] ?? null;
|
||||||
|
|
||||||
|
$sanitized = $this->sanitizeGroupPolicyDefinitionValue($definitionValue);
|
||||||
|
|
||||||
|
if (! isset($sanitized['definition@odata.bind'])) {
|
||||||
|
$outcomes[] = [
|
||||||
|
'status' => 'skipped',
|
||||||
|
'definition_id' => $definitionId,
|
||||||
|
'definition' => $displayName,
|
||||||
|
'reason' => 'Missing definition@odata.bind',
|
||||||
|
];
|
||||||
|
$summary['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('restore_group_policy_definition_values_create', $context + [
|
||||||
|
'method' => 'POST',
|
||||||
|
'endpoint' => $createPath,
|
||||||
|
'definition_id' => $definitionId,
|
||||||
|
'definition' => $displayName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createResponse = $this->graphClient->request('POST', $createPath, [
|
||||||
|
'json' => $sanitized,
|
||||||
|
] + $graphOptions);
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('restore_group_policy_definition_values_create', $createResponse, $context + [
|
||||||
|
'method' => 'POST',
|
||||||
|
'endpoint' => $createPath,
|
||||||
|
'definition_id' => $definitionId,
|
||||||
|
'definition' => $displayName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($createResponse->successful()) {
|
||||||
|
$outcomes[] = [
|
||||||
|
'status' => 'success',
|
||||||
|
'definition_id' => $definitionId,
|
||||||
|
'definition' => $displayName,
|
||||||
|
];
|
||||||
|
$summary['success']++;
|
||||||
|
} else {
|
||||||
|
$outcomes[] = array_filter([
|
||||||
|
'status' => 'failed',
|
||||||
|
'definition_id' => $definitionId,
|
||||||
|
'definition' => $displayName,
|
||||||
|
'reason' => $createResponse->meta['error_message'] ?? 'Graph create failed',
|
||||||
|
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
||||||
|
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
||||||
|
'graph_request_id' => $createResponse->meta['request_id'] ?? null,
|
||||||
|
'graph_client_request_id' => $createResponse->meta['client_request_id'] ?? null,
|
||||||
|
], static fn ($value) => $value !== null);
|
||||||
|
$summary['failed']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore.group_policy_definition_values.applied',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'summary' => $summary,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
status: ($summary['failed'] ?? 0) > 0 ? 'warning' : 'success',
|
||||||
|
resourceType: 'policy',
|
||||||
|
resourceId: $policyId
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'outcomes' => $outcomes,
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $definitionValue
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sanitizeGroupPolicyDefinitionValue(array $definitionValue): array
|
||||||
|
{
|
||||||
|
$clean = [];
|
||||||
|
|
||||||
|
foreach ($definitionValue as $key => $value) {
|
||||||
|
if (is_string($key) && str_starts_with($key, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'id') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'presentationValues' && is_array($value)) {
|
||||||
|
$cleanPresentationValues = [];
|
||||||
|
|
||||||
|
foreach ($value as $presentationValue) {
|
||||||
|
if (! is_array($presentationValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationClean = [];
|
||||||
|
|
||||||
|
foreach ($presentationValue as $pKey => $pValue) {
|
||||||
|
if (is_string($pKey) && str_starts_with($pKey, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($pKey, ['id', 'createdDateTime', 'lastModifiedDateTime', 'presentation'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presentationClean[$pKey] = $pValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($presentationClean !== []) {
|
||||||
|
$cleanPresentationValues[] = $presentationClean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cleanPresentationValues !== []) {
|
||||||
|
$clean['presentationValues'] = $cleanPresentationValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $foundationEntries
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildScopeTagNameLookup(array $foundationEntries): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($foundationEntries as $entry) {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($entry['type'] ?? null) !== 'roleScopeTag') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetId = $entry['targetId'] ?? null;
|
||||||
|
$targetName = $entry['targetName'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetId) || $targetId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($targetName) || $targetName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names[$targetId] = $targetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed>|null $scopeTagIds
|
||||||
|
* @param array<string, mixed> $backupItemMetadata
|
||||||
|
* @param array<string, string> $scopeTagMapping
|
||||||
|
* @param array<string, string> $scopeTagNamesById
|
||||||
|
* @return array{ids: array<int, string>, names: array<int, string>}|null
|
||||||
|
*/
|
||||||
|
private function buildScopeTagsForVersion(
|
||||||
|
?array $scopeTagIds,
|
||||||
|
array $backupItemMetadata,
|
||||||
|
array $scopeTagMapping,
|
||||||
|
array $scopeTagNamesById,
|
||||||
|
): ?array {
|
||||||
|
if ($scopeTagIds === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ($scopeTagIds as $id) {
|
||||||
|
if (! is_string($id) && ! is_int($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (string) $id;
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_unique($ids));
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$namesById = $scopeTagNamesById;
|
||||||
|
|
||||||
|
$metaScopeTagIds = $backupItemMetadata['scope_tag_ids'] ?? null;
|
||||||
|
$metaScopeTagNames = $backupItemMetadata['scope_tag_names'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($metaScopeTagIds) && is_array($metaScopeTagNames)) {
|
||||||
|
foreach ($metaScopeTagIds as $index => $sourceId) {
|
||||||
|
if (! is_string($sourceId) && ! is_int($sourceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = (string) $sourceId;
|
||||||
|
|
||||||
|
if ($sourceId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $metaScopeTagNames[$index] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetId = $scopeTagMapping[$sourceId] ?? $sourceId;
|
||||||
|
|
||||||
|
if ($targetId !== '' && ! array_key_exists($targetId, $namesById)) {
|
||||||
|
$namesById[$targetId] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if ($id === '0') {
|
||||||
|
$names[] = 'Default';
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names[] = $namesById[$id] ?? "Unknown (ID: {$id})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ids' => $ids,
|
||||||
|
'names' => $names,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
|
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
|
||||||
{
|
{
|
||||||
if (! $tenant->isActive()) {
|
if (! $tenant->isActive()) {
|
||||||
|
|||||||
@ -26,6 +26,121 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
|
|||||||
*/
|
*/
|
||||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||||
{
|
{
|
||||||
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
|
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
|
||||||
|
$title = $normalized['settings_table']['title'] ?? 'Settings';
|
||||||
|
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
||||||
|
$rows = $normalized['settings_table']['rows'];
|
||||||
|
|
||||||
|
$baseLabels = array_values(array_filter(array_map(function (mixed $row): ?string {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildSettingsCatalogDiffLabel($row, includePath: false);
|
||||||
|
}, $rows)));
|
||||||
|
|
||||||
|
$labelCounts = array_count_values($baseLabels);
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseLabel = $this->buildSettingsCatalogDiffLabel($row, includePath: false);
|
||||||
|
$label = $baseLabel;
|
||||||
|
|
||||||
|
if (($labelCounts[$baseLabel] ?? 0) > 1) {
|
||||||
|
$path = $row['path'] ?? null;
|
||||||
|
$pathLabel = is_string($path) && $path !== '' ? $path : null;
|
||||||
|
|
||||||
|
$label = $this->buildSettingsCatalogDiffLabel($row, includePath: true);
|
||||||
|
|
||||||
|
if ($pathLabel !== null) {
|
||||||
|
$label .= ' @ '.$pathLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $prefix.$label;
|
||||||
|
$map[$key] = $row['value'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($normalized['settings'] ?? [] as $block) {
|
||||||
|
if (! is_array($block)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $block['title'] ?? null;
|
||||||
|
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
||||||
|
|
||||||
|
if (($block['type'] ?? null) === 'table') {
|
||||||
|
foreach ($block['rows'] ?? [] as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $prefix.($row['path'] ?? $row['label'] ?? 'entry');
|
||||||
|
$map[$key] = $row['value'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($block['entries'] ?? [] as $entry) {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $prefix.($entry['key'] ?? 'entry');
|
||||||
|
$map[$key] = $entry['value'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function buildSettingsCatalogDiffLabel(array $row, bool $includePath): string
|
||||||
|
{
|
||||||
|
$category = $row['category'] ?? null;
|
||||||
|
$definition = $row['definition'] ?? null;
|
||||||
|
$definitionId = $row['definition_id'] ?? null;
|
||||||
|
|
||||||
|
$label = is_string($definition) && $definition !== '' ? $definition : 'Setting';
|
||||||
|
|
||||||
|
if ($includePath) {
|
||||||
|
$path = $row['path'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($path) && $path !== '') {
|
||||||
|
$label = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
is_string($label)
|
||||||
|
&& is_string($definitionId)
|
||||||
|
&& $definitionId !== ''
|
||||||
|
&& is_string($definition)
|
||||||
|
&& $definition !== ''
|
||||||
|
) {
|
||||||
|
$parts = explode(' > ', $label);
|
||||||
|
|
||||||
|
if ($parts !== [] && end($parts) === $definitionId) {
|
||||||
|
$parts[count($parts) - 1] = $definition;
|
||||||
|
$label = implode(' > ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($category) && $category !== '' && $category !== '-') {
|
||||||
|
$label = $category.' > '.$label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,26 @@
|
|||||||
'update_method' => 'PATCH',
|
'update_method' => 'PATCH',
|
||||||
'id_field' => 'id',
|
'id_field' => 'id',
|
||||||
'hydration' => 'properties',
|
'hydration' => 'properties',
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'definitionValues',
|
||||||
|
],
|
||||||
|
'member_hydration_strategy' => 'subresource_definition_values',
|
||||||
|
'subresources' => [
|
||||||
|
'definitionValues' => [
|
||||||
|
'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues?$expand=definition',
|
||||||
|
'collection' => true,
|
||||||
|
'paging' => true,
|
||||||
|
'allowed_select' => [],
|
||||||
|
'allowed_expand' => [],
|
||||||
|
],
|
||||||
|
'presentationValues' => [
|
||||||
|
'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues/{definitionValueId}/presentationValues?$expand=presentation',
|
||||||
|
'collection' => true,
|
||||||
|
'paging' => true,
|
||||||
|
'allowed_select' => [],
|
||||||
|
'allowed_expand' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments',
|
'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments',
|
||||||
'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign',
|
'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign',
|
||||||
'assignments_create_method' => 'POST',
|
'assignments_create_method' => 'POST',
|
||||||
|
|||||||
@ -1,4 +1,19 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
@php
|
||||||
|
$scopeTags = $version->scope_tags['names'] ?? [];
|
||||||
|
@endphp
|
||||||
|
@if(!empty($scopeTags))
|
||||||
|
<x-filament::section heading="Scope Tags">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($scopeTags as $tag)
|
||||||
|
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
|
||||||
|
{{ $tag }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if($version->assignments && count($version->assignments) > 0)
|
@if($version->assignments && count($version->assignments) > 0)
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
heading="Assignments"
|
heading="Assignments"
|
||||||
@ -18,22 +33,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php
|
|
||||||
$scopeTags = $version->scope_tags['names'] ?? [];
|
|
||||||
@endphp
|
|
||||||
@if(!empty($scopeTags))
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
@foreach($scopeTags as $tag)
|
|
||||||
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
|
|
||||||
{{ $tag }}
|
|
||||||
</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
|
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
|
|||||||
22
specs/010-admin-templates/plan.md
Normal file
22
specs/010-admin-templates/plan.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Implementation Plan: Administrative Templates (010)
|
||||||
|
|
||||||
|
**Branch**: `feat/010-admin-templates`
|
||||||
|
**Date**: 2025-12-29
|
||||||
|
**Spec Source**: [spec.md](./spec.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Make `groupPolicyConfiguration` snapshots/restores accurate by hydrating and applying `definitionValues` and their `presentationValues`, and present a readable normalized view in Filament.
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
1. Graph contract updates
|
||||||
|
- Add subresource/hydration metadata for `definitionValues` + `presentationValues`.
|
||||||
|
2. Snapshot capture hydration
|
||||||
|
- Extend `PolicySnapshotService` to hydrate Admin Template settings into the payload.
|
||||||
|
3. Restore
|
||||||
|
- Extend `RestoreService` to “wipe and replace” definitionValues/presentationValues from snapshot.
|
||||||
|
4. UI normalization
|
||||||
|
- Add a normalizer that renders configured settings as readable rows.
|
||||||
|
5. Tests + formatting
|
||||||
|
- Add targeted Pest tests for snapshot hydration, normalized display, and restore.
|
||||||
|
- Run `./vendor/bin/pint --dirty` and the affected tests.
|
||||||
|
|
||||||
52
specs/010-admin-templates/spec.md
Normal file
52
specs/010-admin-templates/spec.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Feature Specification: Administrative Templates (Group Policy Configurations) (010)
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/010-admin-templates`
|
||||||
|
**Created**: 2025-12-29
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: `.specify/spec.md` (groupPolicyConfiguration scope), `references/IntuneManagement-master` (definitionValues/presentationValues pattern)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Add reliable coverage for **Administrative Templates** (`groupPolicyConfiguration`) in the existing inventory/backup/version/restore flows.
|
||||||
|
|
||||||
|
Administrative Templates are not fully represented by the base entity alone; the effective policy settings live in:
|
||||||
|
- `definitionValues` (with expanded `definition`)
|
||||||
|
- `presentationValues` per definitionValue (with expanded `presentation`)
|
||||||
|
|
||||||
|
## In Scope
|
||||||
|
- Policy type: `groupPolicyConfiguration` (`deviceManagement/groupPolicyConfigurations`)
|
||||||
|
- Snapshot capture hydrates:
|
||||||
|
- `definitionValues?$expand=definition`
|
||||||
|
- `presentationValues?$expand=presentation` for each definitionValue
|
||||||
|
- Restore supports “snapshot as source of truth” for Admin Templates settings:
|
||||||
|
- delete existing definitionValues
|
||||||
|
- recreate definitionValues + presentationValues from snapshot
|
||||||
|
- UI shows a readable “Normalized settings” view for Admin Templates (definitions + values).
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
- Translating every ADMX value into Intune-portal-identical wording for every template
|
||||||
|
- Advanced partial-restore / per-setting selection
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Inventory + readable view (P1)
|
||||||
|
As an admin, I can open an Administrative Template policy and see its effective configured settings (not only metadata).
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
1. Policy detail shows a structured list/table of configured settings (definition + value).
|
||||||
|
2. Policy Versions store the hydrated settings and render them in “Normalized settings”.
|
||||||
|
|
||||||
|
### User Story 2 — Backup/Version capture includes definition values (P1)
|
||||||
|
As an admin, a backup/version of an Administrative Template includes the `definitionValues` + `presentationValues`.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
1. Backup payload contains `definitionValues` array.
|
||||||
|
2. Each definitionValue includes expanded `definition` and a `presentationValues` collection (when present).
|
||||||
|
|
||||||
|
### User Story 3 — Restore settings (P1)
|
||||||
|
As an admin, restoring an Administrative Template brings the target tenant’s definition values back to the snapshot state.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
1. Restore deletes existing definitionValues before recreate.
|
||||||
|
2. Restore recreates definitionValues and their presentationValues.
|
||||||
|
3. Clear per-item audit outcomes on failures.
|
||||||
|
|
||||||
29
specs/010-admin-templates/tasks.md
Normal file
29
specs/010-admin-templates/tasks.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Tasks: Administrative Templates (Group Policy Configurations) (010)
|
||||||
|
|
||||||
|
**Branch**: `feat/010-admin-templates` | **Date**: 2025-12-29
|
||||||
|
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
|
||||||
|
|
||||||
|
## Phase 1: Contracts + Snapshot Hydration
|
||||||
|
- [x] T001 Extend `config/graph_contracts.php` for `groupPolicyConfiguration` (hydration/subresources metadata).
|
||||||
|
- [x] T002 Hydrate `definitionValues` (+ `presentationValues`) in `app/Services/Intune/PolicySnapshotService.php`.
|
||||||
|
|
||||||
|
## Phase 2: Restore (Definition Values)
|
||||||
|
- [x] T003 Implement restore apply for `definitionValues` and `presentationValues` in `app/Services/Intune/RestoreService.php`.
|
||||||
|
|
||||||
|
## Phase 3: UI Normalization
|
||||||
|
- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||||
|
- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`.
|
||||||
|
- [x] T010 Ensure restore-created versions keep assignments + show scope tags independently.
|
||||||
|
- [x] T012 Add “Restore to Intune” from a specific PolicyVersion (rollback) + show policy version numbers in restore selection UI.
|
||||||
|
|
||||||
|
## Phase 4: Tests + Verification
|
||||||
|
- [x] T005 Add tests for hydration + UI display.
|
||||||
|
- [x] T006 Add tests for restore definitionValues apply.
|
||||||
|
- [x] T007 Run tests (targeted).
|
||||||
|
- [x] T008 Run Pint (`./vendor/bin/pint --dirty`).
|
||||||
|
- [x] T011 Fix Admin Templates hydration paging (`@odata.nextLink`) and add coverage.
|
||||||
|
|
||||||
|
## Open TODOs (Follow-up)
|
||||||
|
- [ ] Improve Admin Templates value formatting to better match Intune UI (presentation values / multi-value rendering).
|
||||||
|
- [ ] Add a small “snapshot completeness” indicator (e.g., definitionValues count) to reduce confusion when older/incomplete snapshots exist.
|
||||||
|
- [ ] Implement remaining Windows “Templates” profile types (separate policy types/odata subtypes; new specs per type).
|
||||||
144
tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php
Normal file
144
tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
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\VersionService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class GroupPolicyHydrationGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public array $requests = [];
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->requests[] = ['getPolicy', $policyType, $policyId];
|
||||||
|
|
||||||
|
return new GraphResponse(true, ['payload' => [
|
||||||
|
'id' => $policyId,
|
||||||
|
'displayName' => 'Admin Templates Alpha',
|
||||||
|
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->requests[] = [strtoupper($method), $path];
|
||||||
|
|
||||||
|
if (str_contains($path, '/definitionValues') && str_contains($path, '$expand=definition')) {
|
||||||
|
return new GraphResponse(true, [
|
||||||
|
'value' => [
|
||||||
|
[
|
||||||
|
'id' => 'dv-1',
|
||||||
|
'enabled' => true,
|
||||||
|
'definition' => [
|
||||||
|
'id' => 'def-1',
|
||||||
|
'displayName' => 'Block legacy auth',
|
||||||
|
'classType' => 'user',
|
||||||
|
'categoryPath' => 'Windows Components\\Security Options',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($path, '/presentationValues') && str_contains($path, '$expand=presentation')) {
|
||||||
|
return new GraphResponse(true, [
|
||||||
|
'value' => [
|
||||||
|
[
|
||||||
|
'id' => 'pv-1',
|
||||||
|
'value' => 'enabled',
|
||||||
|
'presentation' => [
|
||||||
|
'id' => 'pres-1',
|
||||||
|
'label' => 'State',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('group policy configuration snapshot hydrates definition values and renders in policy detail', function () {
|
||||||
|
$client = new GroupPolicyHydrationGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-gpo-hydration',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||||
|
$_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id;
|
||||||
|
$_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id;
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'gpo-hydrate',
|
||||||
|
'policy_type' => 'groupPolicyConfiguration',
|
||||||
|
'display_name' => 'Admin Templates Alpha',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var BackupService $backupService */
|
||||||
|
$backupService = app(BackupService::class);
|
||||||
|
$backupSet = $backupService->createBackupSet($tenant, [$policy->id], actorEmail: 'tester@example.com');
|
||||||
|
|
||||||
|
$item = $backupSet->items()->first();
|
||||||
|
expect($item->payload)->toHaveKey('definitionValues');
|
||||||
|
expect($item->payload['definitionValues'])->toBeArray();
|
||||||
|
expect($item->payload['definitionValues'][0])->toHaveKey('definition@odata.bind');
|
||||||
|
expect($item->payload['definitionValues'][0])->toHaveKey('presentationValues');
|
||||||
|
expect($item->payload['definitionValues'][0]['presentationValues'][0])->toHaveKey('presentation@odata.bind');
|
||||||
|
expect($item->payload['definitionValues'][0]['#Definition_displayName'])->toBe('Block legacy auth');
|
||||||
|
expect($item->payload['definitionValues'][0]['presentationValues'][0]['#Presentation_Label'])->toBe('State');
|
||||||
|
|
||||||
|
/** @var VersionService $versions */
|
||||||
|
$versions = app(VersionService::class);
|
||||||
|
$versions->captureVersion(
|
||||||
|
policy: $policy,
|
||||||
|
payload: $item->payload,
|
||||||
|
createdBy: 'tester@example.com',
|
||||||
|
metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->get(route('filament.admin.resources.policies.view', ['record' => $policy]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Block legacy auth');
|
||||||
|
$response->assertSee('State');
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
|
||||||
|
test('group policy configuration normalized diff keys use definition display names', function () {
|
||||||
|
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
||||||
|
snapshot: [
|
||||||
|
'id' => 'gpo-1',
|
||||||
|
'displayName' => 'Admin Templates Alpha',
|
||||||
|
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||||
|
'definitionValues' => [
|
||||||
|
[
|
||||||
|
'enabled' => true,
|
||||||
|
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||||
|
'#Definition_Id' => 'def-1',
|
||||||
|
'#Definition_displayName' => 'Block legacy auth',
|
||||||
|
'#Definition_categoryPath' => 'Windows Components\\Security Options',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
policyType: 'groupPolicyConfiguration',
|
||||||
|
platform: 'windows',
|
||||||
|
);
|
||||||
|
|
||||||
|
$keys = array_keys($flat);
|
||||||
|
|
||||||
|
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
|
||||||
|
expect(implode("\n", $keys))->not->toContain('graph.microsoft.com');
|
||||||
|
});
|
||||||
183
tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php
Normal file
183
tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class GroupPolicyRestoreGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{policy_type:string,policy_id:string,payload:array}>
|
||||||
|
*/
|
||||||
|
public array $applyPolicyCalls = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||||
|
*/
|
||||||
|
public array $requestCalls = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, GraphResponse> $requestResponses
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphResponse $applyPolicyResponse,
|
||||||
|
private array $requestResponses = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, ['payload' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->applyPolicyCalls[] = [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'payload' => $payload,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->applyPolicyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$this->requestCalls[] = [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'path' => $path,
|
||||||
|
'payload' => $options['json'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = array_shift($this->requestResponses);
|
||||||
|
|
||||||
|
return $response ?? new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('restore applies administrative template definition values', function () {
|
||||||
|
$policyResponse = new GraphResponse(true, [], 200, [], [], ['request_id' => 'req-policy', 'client_request_id' => 'client-policy']);
|
||||||
|
|
||||||
|
$listExisting = new GraphResponse(true, [
|
||||||
|
'value' => [
|
||||||
|
['id' => 'existing-dv-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleteExisting = new GraphResponse(true, []);
|
||||||
|
$createDefinitionValue = new GraphResponse(true, []);
|
||||||
|
|
||||||
|
$client = new GroupPolicyRestoreGraphClient($policyResponse, [
|
||||||
|
$listExisting,
|
||||||
|
$deleteExisting,
|
||||||
|
$createDefinitionValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-gpo-restore',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'gpo-1',
|
||||||
|
'policy_type' => 'groupPolicyConfiguration',
|
||||||
|
'display_name' => 'Admin Templates Alpha',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'id' => 'gpo-1',
|
||||||
|
'displayName' => 'Admin Templates Alpha',
|
||||||
|
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||||
|
'definitionValues' => [
|
||||||
|
[
|
||||||
|
'enabled' => true,
|
||||||
|
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
|
||||||
|
'#Definition_Id' => 'def-1',
|
||||||
|
'#Definition_displayName' => 'Block legacy auth',
|
||||||
|
'presentationValues' => [
|
||||||
|
[
|
||||||
|
'presentation@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')/presentations(\'pres-1\')',
|
||||||
|
'#Presentation_Label' => 'State',
|
||||||
|
'#Presentation_Id' => 'pres-1',
|
||||||
|
'value' => 'enabled',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$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,
|
||||||
|
'payload' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$run = $service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$backupItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
)->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->results[0]['status'])->toBe('applied');
|
||||||
|
expect($run->results[0]['definition_value_summary']['success'])->toBe(1);
|
||||||
|
|
||||||
|
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||||
|
expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration');
|
||||||
|
|
||||||
|
expect($client->requestCalls)->toHaveCount(3);
|
||||||
|
expect($client->requestCalls[0]['method'])->toBe('GET');
|
||||||
|
expect($client->requestCalls[0]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues');
|
||||||
|
expect($client->requestCalls[1]['method'])->toBe('DELETE');
|
||||||
|
expect($client->requestCalls[1]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues/existing-dv-1');
|
||||||
|
expect($client->requestCalls[2]['method'])->toBe('POST');
|
||||||
|
expect($client->requestCalls[2]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues');
|
||||||
|
expect($client->requestCalls[2]['payload'])->toBeArray();
|
||||||
|
expect($client->requestCalls[2]['payload'])->toHaveKey('definition@odata.bind');
|
||||||
|
expect($client->requestCalls[2]['payload'])->not->toHaveKey('#Definition_displayName');
|
||||||
|
});
|
||||||
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class PolicyVersionRestoreGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||||
|
*/
|
||||||
|
public array $requestCalls = [];
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, ['payload' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
$method = strtoupper($method);
|
||||||
|
|
||||||
|
$this->requestCalls[] = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'payload' => $options['json'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
return new GraphResponse(true, ['value' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('restore can execute from a specific policy version snapshot', function () {
|
||||||
|
$client = new PolicyVersionRestoreGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-version-restore',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'gpo-versioned-1',
|
||||||
|
'policy_type' => 'groupPolicyConfiguration',
|
||||||
|
'display_name' => 'Admin Templates',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshotWithThree = [
|
||||||
|
'id' => $policy->external_id,
|
||||||
|
'displayName' => $policy->display_name,
|
||||||
|
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||||
|
'definitionValues' => collect(range(1, 3))
|
||||||
|
->map(fn (int $i) => [
|
||||||
|
'enabled' => true,
|
||||||
|
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
|
||||||
|
'#Definition_Id' => "def-{$i}",
|
||||||
|
'#Definition_displayName' => "Setting {$i}",
|
||||||
|
])->all(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$snapshotWithFive = [
|
||||||
|
'id' => $policy->external_id,
|
||||||
|
'displayName' => $policy->display_name,
|
||||||
|
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||||
|
'definitionValues' => collect(range(1, 5))
|
||||||
|
->map(fn (int $i) => [
|
||||||
|
'enabled' => true,
|
||||||
|
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
|
||||||
|
'#Definition_Id' => "def-{$i}",
|
||||||
|
'#Definition_displayName' => "Setting {$i}",
|
||||||
|
])->all(),
|
||||||
|
];
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => $snapshotWithThree,
|
||||||
|
'metadata' => ['source' => 'version_capture'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$versionToRestore = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => $snapshotWithFive,
|
||||||
|
'metadata' => ['source' => 'version_capture'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$run = $service->executeFromPolicyVersion(
|
||||||
|
tenant: $tenant,
|
||||||
|
version: $versionToRestore,
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
)->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->results[0]['status'])->toBe('applied');
|
||||||
|
expect($run->results[0]['definition_value_summary']['success'])->toBe(5);
|
||||||
|
|
||||||
|
$definitionValueCreateCalls = collect($client->requestCalls)
|
||||||
|
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($definitionValueCreateCalls)->toHaveCount(5);
|
||||||
|
});
|
||||||
59
tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php
Normal file
59
tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy version view shows scope tags even when assignments are missing', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Policy A',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'Policy A',
|
||||||
|
],
|
||||||
|
'assignments' => null,
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0', 'scope-1'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Scope Tags');
|
||||||
|
$response->assertSee('Default');
|
||||||
|
$response->assertSee('Verbund-1');
|
||||||
|
});
|
||||||
@ -49,6 +49,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
config()->set('graph_contracts.types.deviceConfiguration.assignments_payload_key', 'assignments');
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-1',
|
'tenant_id' => 'tenant-1',
|
||||||
'name' => 'Tenant One',
|
'name' => 'Tenant One',
|
||||||
@ -77,7 +79,24 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
'policy_identifier' => $policy->external_id,
|
'policy_identifier' => $policy->external_id,
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
'payload' => ['foo' => 'bar'],
|
'payload' => [
|
||||||
|
'foo' => 'bar',
|
||||||
|
'roleScopeTagIds' => ['0', 'scope-1'],
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'scope_tag_ids' => ['0', 'scope-1'],
|
||||||
|
'scope_tag_names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-1',
|
||||||
|
'group_display_name' => 'Group One',
|
||||||
|
],
|
||||||
|
'intent' => 'apply',
|
||||||
|
],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
@ -102,6 +121,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
|
||||||
|
|
||||||
|
$version = PolicyVersion::where('policy_id', $policy->id)->first();
|
||||||
|
expect($version)->not->toBeNull();
|
||||||
|
expect($version->scope_tags)->toBe([
|
||||||
|
'ids' => ['0', 'scope-1'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
]);
|
||||||
|
expect($version->assignments)->toBe($backupItem->assignments);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restore execution records foundation mappings', function () {
|
test('restore execution records foundation mappings', function () {
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\SettingsCatalogCategory;
|
||||||
|
use App\Models\SettingsCatalogDefinition;
|
||||||
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('settings catalog normalized diff keys use definition display names', function () {
|
||||||
|
SettingsCatalogCategory::create([
|
||||||
|
'category_id' => 'cat-1',
|
||||||
|
'display_name' => 'Account Management',
|
||||||
|
'description' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SettingsCatalogDefinition::create([
|
||||||
|
'definition_id' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||||
|
'display_name' => 'Deletion Policy',
|
||||||
|
'description' => null,
|
||||||
|
'help_text' => null,
|
||||||
|
'category_id' => 'cat-1',
|
||||||
|
'ux_behavior' => null,
|
||||||
|
'raw' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$flat = app(PolicyNormalizer::class)->flattenForDiff(
|
||||||
|
snapshot: [
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||||
|
'id' => 'scp-policy-1',
|
||||||
|
'name' => 'Settings Catalog Policy',
|
||||||
|
'platforms' => 'windows10',
|
||||||
|
'technologies' => 'mdm',
|
||||||
|
'settings' => [
|
||||||
|
[
|
||||||
|
'id' => 's1',
|
||||||
|
'settingInstance' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
|
||||||
|
'settingDefinitionId' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
|
||||||
|
'choiceSettingValue' => [
|
||||||
|
'value' => 'enabled',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
policyType: 'settingsCatalogPolicy',
|
||||||
|
platform: 'windows',
|
||||||
|
);
|
||||||
|
|
||||||
|
$keys = array_keys($flat);
|
||||||
|
|
||||||
|
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
|
||||||
|
expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user