feat(006): foundations + assignment mapping and preview-only restore guard #7
@ -362,7 +362,12 @@ private static function typeMeta(?string $type): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
$types = array_merge(
|
||||||
|
config('tenantpilot.supported_policy_types', []),
|
||||||
|
config('tenantpilot.foundation_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,10 @@ public function table(Table $table): Table
|
|||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Policy')
|
->label('Item')
|
||||||
->sortable()
|
->sortable()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -112,6 +113,10 @@ public function table(Table $table): Table
|
|||||||
->label('Include scope tags')
|
->label('Include scope tags')
|
||||||
->default(true)
|
->default(true)
|
||||||
->helperText('Captures policy scope tag IDs.'),
|
->helperText('Captures policy scope tag IDs.'),
|
||||||
|
Forms\Components\Checkbox::make('include_foundations')
|
||||||
|
->label('Include foundations')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Captures assignment filters, scope tags, and notification templates.'),
|
||||||
])
|
])
|
||||||
->action(function (array $data, BackupService $service) {
|
->action(function (array $data, BackupService $service) {
|
||||||
if (empty($data['policy_ids'])) {
|
if (empty($data['policy_ids'])) {
|
||||||
@ -134,10 +139,15 @@ public function table(Table $table): Table
|
|||||||
actorName: auth()->user()?->name,
|
actorName: auth()->user()?->name,
|
||||||
includeAssignments: $data['include_assignments'] ?? false,
|
includeAssignments: $data['include_assignments'] ?? false,
|
||||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||||
|
includeFoundations: $data['include_foundations'] ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$notificationTitle = ($data['include_foundations'] ?? false)
|
||||||
|
? 'Backup items added'
|
||||||
|
: 'Policies added to backup';
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policies added to backup')
|
->title($notificationTitle)
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
@ -191,7 +201,12 @@ private static function typeMeta(?string $type): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
$types = array_merge(
|
||||||
|
config('tenantpilot.supported_policy_types', []),
|
||||||
|
config('tenantpilot.foundation_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,38 +75,14 @@ public static function form(Schema $schema): Schema
|
|||||||
->required(),
|
->required(),
|
||||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||||
->label('Items to restore (optional)')
|
->label('Items to restore (optional)')
|
||||||
->options(function (Get $get) {
|
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
|
||||||
$backupSetId = $get('backup_set_id');
|
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
|
||||||
if (! $backupSetId) {
|
->columns(1)
|
||||||
return [];
|
->searchable()
|
||||||
}
|
->bulkToggleable()
|
||||||
|
|
||||||
return BackupItem::query()
|
|
||||||
->where('backup_set_id', $backupSetId)
|
|
||||||
->whereHas('backupSet', function ($query) {
|
|
||||||
$tenantId = Tenant::current()->getKey();
|
|
||||||
$query->where('tenant_id', $tenantId);
|
|
||||||
})
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(function (BackupItem $item) {
|
|
||||||
$meta = static::typeMeta($item->policy_type);
|
|
||||||
$typeLabel = $meta['label'] ?? $item->policy_type;
|
|
||||||
$restore = $meta['restore'] ?? 'enabled';
|
|
||||||
|
|
||||||
$label = sprintf(
|
|
||||||
'%s (%s • restore: %s)',
|
|
||||||
$item->policy_identifier ?? $item->policy_type,
|
|
||||||
$typeLabel,
|
|
||||||
$restore
|
|
||||||
);
|
|
||||||
|
|
||||||
return [$item->id => $label];
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->columns(2)
|
|
||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||||
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
|
->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'),
|
||||||
Section::make('Group mapping')
|
Section::make('Group mapping')
|
||||||
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||||
->schema(function (Get $get): array {
|
->schema(function (Get $get): array {
|
||||||
@ -182,6 +158,75 @@ public static function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('rerun')
|
||||||
|
->label('Rerun')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(function (RestoreRun $record): bool {
|
||||||
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
|
return $record->isDeletable()
|
||||||
|
&& $backupSet !== null
|
||||||
|
&& ! $backupSet->trashed();
|
||||||
|
})
|
||||||
|
->action(function (
|
||||||
|
RestoreRun $record,
|
||||||
|
RestoreService $restoreService,
|
||||||
|
\App\Services\Intune\AuditLogger $auditLogger
|
||||||
|
) {
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSet || $backupSet->trashed()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run cannot be rerun')
|
||||||
|
->body('Backup set is archived or unavailable.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newRun = $restoreService->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $record->requested_items ?? null,
|
||||||
|
dryRun: (bool) $record->is_dry_run,
|
||||||
|
actorEmail: auth()->user()?->email,
|
||||||
|
actorName: auth()->user()?->name,
|
||||||
|
groupMapping: $record->group_mapping ?? []
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run failed to start')
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'restore_run.rerun',
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $newRun->id,
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'original_restore_run_id' => $record->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run started')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
@ -465,10 +510,82 @@ private static function typeMeta(?string $type): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
$types = array_merge(
|
||||||
|
config('tenantpilot.supported_policy_types', []),
|
||||||
|
config('tenantpilot.foundation_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{options: array<int, string>, descriptions: array<int, string>}
|
||||||
|
*/
|
||||||
|
private static function restoreItemOptionData(?int $backupSetId): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant || ! $backupSetId) {
|
||||||
|
return [
|
||||||
|
'options' => [],
|
||||||
|
'descriptions' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static $cache = [];
|
||||||
|
$cacheKey = $tenant->getKey().':'.$backupSetId;
|
||||||
|
|
||||||
|
if (isset($cache[$cacheKey])) {
|
||||||
|
return $cache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = BackupItem::query()
|
||||||
|
->where('backup_set_id', $backupSetId)
|
||||||
|
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
|
||||||
|
->with('policy:id,display_name')
|
||||||
|
->get()
|
||||||
|
->sortBy(function (BackupItem $item) {
|
||||||
|
$meta = static::typeMeta($item->policy_type);
|
||||||
|
$category = $meta['category'] ?? 'Policies';
|
||||||
|
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
|
||||||
|
$name = strtolower($item->resolvedDisplayName());
|
||||||
|
|
||||||
|
return strtolower($categoryKey.'-'.$name);
|
||||||
|
});
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
$descriptions = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$meta = static::typeMeta($item->policy_type);
|
||||||
|
$typeLabel = $meta['label'] ?? $item->policy_type;
|
||||||
|
$category = $meta['category'] ?? 'Policies';
|
||||||
|
$restore = $meta['restore'] ?? 'enabled';
|
||||||
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||||
|
$displayName = $item->resolvedDisplayName();
|
||||||
|
$identifier = $item->policy_identifier ?? null;
|
||||||
|
|
||||||
|
$options[$item->id] = $displayName;
|
||||||
|
|
||||||
|
$parts = array_filter([
|
||||||
|
$category,
|
||||||
|
$typeLabel,
|
||||||
|
$platform,
|
||||||
|
"restore: {$restore}",
|
||||||
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||||
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$descriptions[$item->id] = implode(' • ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache[$cacheKey] = [
|
||||||
|
'options' => $options,
|
||||||
|
'descriptions' => $descriptions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function createRestoreRun(array $data): RestoreRun
|
public static function createRestoreRun(array $data): RestoreRun
|
||||||
{
|
{
|
||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
|
|||||||
@ -298,7 +298,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->copyable(),
|
->copyable(),
|
||||||
Infolists\Components\RepeatableEntry::make('permissions')
|
Infolists\Components\RepeatableEntry::make('permissions')
|
||||||
->label('Required permissions')
|
->label('Required permissions')
|
||||||
->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false)['permissions'])
|
->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false, useConfiguredStub: false)['permissions'])
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
|
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
|
||||||
Infolists\Components\TextEntry::make('type')->badge(),
|
Infolists\Components\TextEntry::make('type')->badge(),
|
||||||
|
|||||||
@ -30,6 +30,7 @@ public function __construct(
|
|||||||
public string $policyId,
|
public string $policyId,
|
||||||
public array $assignments,
|
public array $assignments,
|
||||||
public array $groupMapping,
|
public array $groupMapping,
|
||||||
|
public array $foundationMapping = [],
|
||||||
public ?string $actorEmail = null,
|
public ?string $actorEmail = null,
|
||||||
public ?string $actorName = null,
|
public ?string $actorName = null,
|
||||||
) {}
|
) {}
|
||||||
@ -61,6 +62,7 @@ public function handle(AssignmentRestoreService $assignmentRestoreService): arra
|
|||||||
policyId: $this->policyId,
|
policyId: $this->policyId,
|
||||||
assignments: $this->assignments,
|
assignments: $this->assignments,
|
||||||
groupMapping: $this->groupMapping,
|
groupMapping: $this->groupMapping,
|
||||||
|
foundationMapping: $this->foundationMapping,
|
||||||
restoreRun: $restoreRun,
|
restoreRun: $restoreRun,
|
||||||
actorEmail: $this->actorEmail,
|
actorEmail: $this->actorEmail,
|
||||||
actorName: $this->actorName,
|
actorName: $this->actorName,
|
||||||
|
|||||||
@ -84,6 +84,34 @@ public function assignmentsFetchFailed(): bool
|
|||||||
return $this->metadata['assignments_fetch_failed'] ?? false;
|
return $this->metadata['assignments_fetch_failed'] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isFoundation(): bool
|
||||||
|
{
|
||||||
|
$types = array_column(config('tenantpilot.foundation_types', []), 'type');
|
||||||
|
|
||||||
|
return in_array($this->policy_type, $types, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvedDisplayName(): string
|
||||||
|
{
|
||||||
|
if ($this->policy) {
|
||||||
|
return $this->policy->display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $this->metadata ?? [];
|
||||||
|
$payload = is_array($this->payload) ? $this->payload : [];
|
||||||
|
$name = $metadata['displayName']
|
||||||
|
?? $metadata['display_name']
|
||||||
|
?? $payload['displayName']
|
||||||
|
?? $payload['name']
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (is_string($name) && $name !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->policy_identifier;
|
||||||
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
public function scopeWithAssignments($query)
|
public function scopeWithAssignments($query)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public function __construct(
|
|||||||
/**
|
/**
|
||||||
* @param array<int, array<string, mixed>> $assignments
|
* @param array<int, array<string, mixed>> $assignments
|
||||||
* @param array<string, string> $groupMapping
|
* @param array<string, string> $groupMapping
|
||||||
|
* @param array<string, array<string, string>> $foundationMapping
|
||||||
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
|
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
|
||||||
*/
|
*/
|
||||||
public function restore(
|
public function restore(
|
||||||
@ -32,6 +33,7 @@ public function restore(
|
|||||||
string $policyId,
|
string $policyId,
|
||||||
array $assignments,
|
array $assignments,
|
||||||
array $groupMapping,
|
array $groupMapping,
|
||||||
|
array $foundationMapping = [],
|
||||||
?RestoreRun $restoreRun = null,
|
?RestoreRun $restoreRun = null,
|
||||||
?string $actorEmail = null,
|
?string $actorEmail = null,
|
||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
@ -80,11 +82,70 @@ public function restore(
|
|||||||
$preparedAssignments = [];
|
$preparedAssignments = [];
|
||||||
$preparedMeta = [];
|
$preparedMeta = [];
|
||||||
|
|
||||||
|
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? [];
|
||||||
|
|
||||||
foreach ($assignments as $assignment) {
|
foreach ($assignments as $assignment) {
|
||||||
if (! is_array($assignment)) {
|
if (! is_array($assignment)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? [];
|
||||||
|
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||||
|
|
||||||
|
if ($filterId !== null) {
|
||||||
|
if ($assignmentFilterMapping === []) {
|
||||||
|
$outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.');
|
||||||
|
$summary['skipped']++;
|
||||||
|
$this->logAssignmentOutcome(
|
||||||
|
status: 'skipped',
|
||||||
|
tenant: $tenant,
|
||||||
|
assignment: $assignment,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
metadata: [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'assignment_filter_id' => $filterId,
|
||||||
|
'reason' => 'Assignment filter mapping is unavailable.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappedFilterId = $assignmentFilterMapping[$filterId] ?? null;
|
||||||
|
|
||||||
|
if ($mappedFilterId === null) {
|
||||||
|
$outcomes[] = $this->skipOutcome(
|
||||||
|
$assignment,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'Assignment filter mapping missing for filter ID.'
|
||||||
|
);
|
||||||
|
$summary['skipped']++;
|
||||||
|
$this->logAssignmentOutcome(
|
||||||
|
status: 'skipped',
|
||||||
|
tenant: $tenant,
|
||||||
|
assignment: $assignment,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
metadata: [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'assignment_filter_id' => $filterId,
|
||||||
|
'reason' => 'Assignment filter mapping missing for filter ID.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||||
|
$assignment['target'] = $target;
|
||||||
|
}
|
||||||
|
|
||||||
$groupId = $assignment['target']['groupId'] ?? null;
|
$groupId = $assignment['target']['groupId'] ?? null;
|
||||||
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
|
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
|
||||||
|
|
||||||
@ -398,13 +459,18 @@ private function successOutcome(array $assignment, ?string $groupId, ?string $ma
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
|
private function skipOutcome(
|
||||||
{
|
array $assignment,
|
||||||
|
?string $groupId,
|
||||||
|
?string $mappedGroupId,
|
||||||
|
?string $reason = null
|
||||||
|
): array {
|
||||||
return [
|
return [
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'assignment' => $this->sanitizeAssignment($assignment),
|
'assignment' => $this->sanitizeAssignment($assignment),
|
||||||
'group_id' => $groupId,
|
'group_id' => $groupId,
|
||||||
'mapped_group_id' => $mappedGroupId,
|
'mapped_group_id' => $mappedGroupId,
|
||||||
|
'reason' => $reason,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public function __construct(
|
|||||||
private readonly PolicySnapshotService $snapshotService,
|
private readonly PolicySnapshotService $snapshotService,
|
||||||
private readonly AssignmentBackupService $assignmentBackupService,
|
private readonly AssignmentBackupService $assignmentBackupService,
|
||||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
|
private readonly FoundationSnapshotService $foundationSnapshots,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,6 +35,7 @@ public function createBackupSet(
|
|||||||
?string $name = null,
|
?string $name = null,
|
||||||
bool $includeAssignments = false,
|
bool $includeAssignments = false,
|
||||||
bool $includeScopeTags = false,
|
bool $includeScopeTags = false,
|
||||||
|
bool $includeFoundations = false,
|
||||||
): BackupSet {
|
): BackupSet {
|
||||||
$this->assertActiveTenant($tenant);
|
$this->assertActiveTenant($tenant);
|
||||||
|
|
||||||
@ -42,7 +44,7 @@ public function createBackupSet(
|
|||||||
->whereIn('id', $policyIds)
|
->whereIn('id', $policyIds)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags) {
|
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
||||||
$backupSet = BackupSet::create([
|
$backupSet = BackupSet::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup',
|
'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup',
|
||||||
@ -75,6 +77,12 @@ public function createBackupSet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($includeFoundations) {
|
||||||
|
$foundationOutcome = $this->captureFoundations($tenant, $backupSet);
|
||||||
|
$itemsCreated += $foundationOutcome['created'] + $foundationOutcome['restored'];
|
||||||
|
$failures = array_merge($failures, $foundationOutcome['failures']);
|
||||||
|
}
|
||||||
|
|
||||||
$status = $this->resolveStatus($itemsCreated, $failures);
|
$status = $this->resolveStatus($itemsCreated, $failures);
|
||||||
|
|
||||||
$backupSet->update([
|
$backupSet->update([
|
||||||
@ -145,6 +153,7 @@ public function addPoliciesToSet(
|
|||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
bool $includeAssignments = false,
|
bool $includeAssignments = false,
|
||||||
bool $includeScopeTags = false,
|
bool $includeScopeTags = false,
|
||||||
|
bool $includeFoundations = false,
|
||||||
): BackupSet {
|
): BackupSet {
|
||||||
$this->assertActiveTenant($tenant);
|
$this->assertActiveTenant($tenant);
|
||||||
|
|
||||||
@ -200,6 +209,12 @@ public function addPoliciesToSet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($includeFoundations) {
|
||||||
|
$foundationOutcome = $this->captureFoundations($tenant, $backupSet);
|
||||||
|
$itemsCreated += $foundationOutcome['created'] + $foundationOutcome['restored'];
|
||||||
|
$failures = array_merge($failures, $foundationOutcome['failures']);
|
||||||
|
}
|
||||||
|
|
||||||
$status = $this->resolveStatus($itemsCreated, $failures);
|
$status = $this->resolveStatus($itemsCreated, $failures);
|
||||||
|
|
||||||
$backupSet->update([
|
$backupSet->update([
|
||||||
@ -307,6 +322,70 @@ private function snapshotPolicy(
|
|||||||
return [$backupItem, null];
|
return [$backupItem, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null}>}
|
||||||
|
*/
|
||||||
|
private function captureFoundations(Tenant $tenant, BackupSet $backupSet): array
|
||||||
|
{
|
||||||
|
$types = config('tenantpilot.foundation_types', []);
|
||||||
|
$created = 0;
|
||||||
|
$restored = 0;
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
foreach ($types as $typeConfig) {
|
||||||
|
$foundationType = $typeConfig['type'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($foundationType) || $foundationType === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->foundationSnapshots->fetchAll($tenant, $foundationType);
|
||||||
|
$failures = array_merge($failures, $result['failures'] ?? []);
|
||||||
|
|
||||||
|
foreach ($result['items'] as $snapshot) {
|
||||||
|
$sourceId = $snapshot['source_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($sourceId) || $sourceId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = BackupItem::withTrashed()
|
||||||
|
->where('backup_set_id', $backupSet->id)
|
||||||
|
->where('policy_type', $foundationType)
|
||||||
|
->where('policy_identifier', $sourceId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->trashed()) {
|
||||||
|
$existing->restore();
|
||||||
|
$restored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => $sourceId,
|
||||||
|
'policy_type' => $foundationType,
|
||||||
|
'platform' => $typeConfig['platform'] ?? null,
|
||||||
|
'payload' => $snapshot['payload'],
|
||||||
|
'metadata' => $snapshot['metadata'] ?? [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'created' => $created,
|
||||||
|
'restored' => $restored,
|
||||||
|
'failures' => $failures,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function assertActiveTenant(Tenant $tenant): void
|
private function assertActiveTenant(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
if (! $tenant->isActive()) {
|
if (! $tenant->isActive()) {
|
||||||
|
|||||||
372
app/Services/Intune/FoundationMappingService.php
Normal file
372
app/Services/Intune/FoundationMappingService.php
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class FoundationMappingService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphClientInterface $graphClient,
|
||||||
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
private readonly FoundationSnapshotService $snapshotService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $items
|
||||||
|
* @return array{entries: array<int, array<string, mixed>>, mapping: array<string, string>, failed: int, skipped: int}
|
||||||
|
*/
|
||||||
|
public function map(Tenant $tenant, Collection $items, bool $execute = false): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
$mapping = [];
|
||||||
|
$failed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'entries' => $entries,
|
||||||
|
'mapping' => $mapping,
|
||||||
|
'failed' => $failed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemsByType = $items->groupBy('policy_type');
|
||||||
|
|
||||||
|
foreach ($itemsByType as $foundationType => $typeItems) {
|
||||||
|
$foundationType = (string) $foundationType;
|
||||||
|
$existingOutcome = $this->snapshotService->fetchAll($tenant, $foundationType);
|
||||||
|
$existingFailures = $existingOutcome['failures'] ?? [];
|
||||||
|
|
||||||
|
if (! empty($existingFailures)) {
|
||||||
|
$reason = $existingFailures[0]['reason'] ?? 'Unable to list foundation resources.';
|
||||||
|
|
||||||
|
foreach ($typeItems as $item) {
|
||||||
|
$entries[] = $this->failureEntry($foundationType, $item, $reason);
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $existingOutcome['items'] ?? [];
|
||||||
|
$existingByName = $this->indexExistingByName($existing);
|
||||||
|
$existingNames = array_keys($existingByName);
|
||||||
|
|
||||||
|
foreach ($typeItems as $item) {
|
||||||
|
$sourceId = $item->policy_identifier;
|
||||||
|
$sourceName = $item->resolvedDisplayName();
|
||||||
|
|
||||||
|
if ($sourceName === '') {
|
||||||
|
$entries[] = $this->skipEntry($foundationType, $sourceId, null, 'Missing display name.');
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = strtolower($sourceName);
|
||||||
|
$matches = $existingByName[$normalizedName] ?? [];
|
||||||
|
|
||||||
|
if (count($matches) === 1) {
|
||||||
|
$match = $matches[0];
|
||||||
|
$targetId = $match['source_id'] ?? null;
|
||||||
|
$targetName = $match['display_name'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($targetId) && $targetId !== '') {
|
||||||
|
$mapping[$sourceId] = $targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $this->decisionEntry(
|
||||||
|
$foundationType,
|
||||||
|
$sourceId,
|
||||||
|
$sourceName,
|
||||||
|
'mapped_existing',
|
||||||
|
$targetId,
|
||||||
|
$targetName,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builtIn = $this->isBuiltInScopeTag($foundationType, $item);
|
||||||
|
|
||||||
|
if ($builtIn) {
|
||||||
|
$entries[] = $this->skipEntry(
|
||||||
|
$foundationType,
|
||||||
|
$sourceId,
|
||||||
|
$sourceName,
|
||||||
|
'Built-in scope tag cannot be created.'
|
||||||
|
);
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = count($matches) > 1 ? 'created_copy' : 'created';
|
||||||
|
$targetName = $decision === 'created_copy'
|
||||||
|
? $this->resolveCopyName($sourceName, $existingNames)
|
||||||
|
: $sourceName;
|
||||||
|
|
||||||
|
if (! $execute) {
|
||||||
|
$entries[] = $this->decisionEntry(
|
||||||
|
$foundationType,
|
||||||
|
$sourceId,
|
||||||
|
$sourceName,
|
||||||
|
$decision,
|
||||||
|
null,
|
||||||
|
$targetName,
|
||||||
|
count($matches) > 1 ? 'Multiple matches found by name.' : null
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createOutcome = $this->createFoundation(
|
||||||
|
tenant: $tenant,
|
||||||
|
foundationType: $foundationType,
|
||||||
|
item: $item,
|
||||||
|
targetName: $targetName
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($createOutcome['success'] ?? false) {
|
||||||
|
$targetId = $createOutcome['target_id'] ?? null;
|
||||||
|
$createdName = $createOutcome['target_name'] ?? $targetName;
|
||||||
|
|
||||||
|
if (is_string($targetId) && $targetId !== '') {
|
||||||
|
$mapping[$sourceId] = $targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $this->decisionEntry(
|
||||||
|
$foundationType,
|
||||||
|
$sourceId,
|
||||||
|
$sourceName,
|
||||||
|
$decision,
|
||||||
|
$targetId,
|
||||||
|
$createdName,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $this->failureEntry(
|
||||||
|
$foundationType,
|
||||||
|
$item,
|
||||||
|
$createOutcome['reason'] ?? 'Failed to create foundation resource.'
|
||||||
|
);
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'entries' => $entries,
|
||||||
|
'mapping' => $mapping,
|
||||||
|
'failed' => $failed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{source_id:string,display_name:?string}> $existing
|
||||||
|
* @return array<string, array<int, array{source_id:string,display_name:?string}>>
|
||||||
|
*/
|
||||||
|
private function indexExistingByName(array $existing): array
|
||||||
|
{
|
||||||
|
$index = [];
|
||||||
|
|
||||||
|
foreach ($existing as $item) {
|
||||||
|
$name = $item['display_name'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index[strtolower($name)][] = [
|
||||||
|
'source_id' => $item['source_id'],
|
||||||
|
'display_name' => $name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCopyName(string $baseName, array $existingNames): string
|
||||||
|
{
|
||||||
|
$suffix = ' (Copy)';
|
||||||
|
$candidate = $baseName.$suffix;
|
||||||
|
$normalized = array_map('strtolower', $existingNames);
|
||||||
|
|
||||||
|
if (! in_array(strtolower($candidate), $normalized, true)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter = 2;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$candidate = sprintf('%s (Copy %d)', $baseName, $counter);
|
||||||
|
|
||||||
|
if (! in_array(strtolower($candidate), $normalized, true)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBuiltInScopeTag(string $foundationType, BackupItem $item): bool
|
||||||
|
{
|
||||||
|
if ($foundationType !== 'roleScopeTag') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item->policy_identifier === '0') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = is_array($item->payload) ? $item->payload : [];
|
||||||
|
|
||||||
|
return (bool) ($payload['isBuiltIn'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, target_id: ?string, target_name: ?string, reason: ?string}
|
||||||
|
*/
|
||||||
|
private function createFoundation(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $foundationType,
|
||||||
|
BackupItem $item,
|
||||||
|
string $targetName
|
||||||
|
): array {
|
||||||
|
$resource = $this->contracts->resourcePath($foundationType);
|
||||||
|
|
||||||
|
if (! $resource) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'target_id' => null,
|
||||||
|
'target_name' => null,
|
||||||
|
'reason' => 'Graph contract resource missing for foundation type.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $this->contracts->get($foundationType);
|
||||||
|
$method = strtoupper((string) ($contract['create_method'] ?? 'POST'));
|
||||||
|
$payload = $this->contracts->sanitizeUpdatePayload($foundationType, is_array($item->payload) ? $item->payload : []);
|
||||||
|
$payload = $this->applyDisplayName($payload, $targetName);
|
||||||
|
|
||||||
|
if ($payload === []) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'target_id' => null,
|
||||||
|
'target_name' => null,
|
||||||
|
'reason' => 'Foundation payload could not be sanitized.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->graphClient->request(
|
||||||
|
$method,
|
||||||
|
$resource,
|
||||||
|
['json' => $payload] + $tenant->graphOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'target_id' => null,
|
||||||
|
'target_name' => null,
|
||||||
|
'reason' => $response->meta['error_message'] ?? 'Graph create failed.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->data;
|
||||||
|
$targetId = is_array($data) ? ($data['id'] ?? null) : null;
|
||||||
|
$targetName = is_array($data) ? ($data['displayName'] ?? $data['name'] ?? $targetName) : $targetName;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'target_id' => is_string($targetId) ? $targetId : null,
|
||||||
|
'target_name' => is_string($targetName) ? $targetName : $targetName,
|
||||||
|
'reason' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function applyDisplayName(array $payload, string $targetName): array
|
||||||
|
{
|
||||||
|
if (array_key_exists('displayName', $payload)) {
|
||||||
|
$payload['displayName'] = $targetName;
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('name', $payload)) {
|
||||||
|
$payload['name'] = $targetName;
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['displayName'] = $targetName;
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionEntry(
|
||||||
|
string $foundationType,
|
||||||
|
string $sourceId,
|
||||||
|
string $sourceName,
|
||||||
|
string $decision,
|
||||||
|
?string $targetId,
|
||||||
|
?string $targetName,
|
||||||
|
?string $reason
|
||||||
|
): array {
|
||||||
|
return array_filter([
|
||||||
|
'type' => $foundationType,
|
||||||
|
'sourceId' => $sourceId,
|
||||||
|
'sourceName' => $sourceName,
|
||||||
|
'decision' => $decision,
|
||||||
|
'targetId' => $targetId,
|
||||||
|
'targetName' => $targetName,
|
||||||
|
'reason' => $reason,
|
||||||
|
], static fn ($value) => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipEntry(
|
||||||
|
string $foundationType,
|
||||||
|
string $sourceId,
|
||||||
|
?string $sourceName,
|
||||||
|
string $reason
|
||||||
|
): array {
|
||||||
|
return $this->decisionEntry(
|
||||||
|
$foundationType,
|
||||||
|
$sourceId,
|
||||||
|
$sourceName ?? $sourceId,
|
||||||
|
'skipped',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failureEntry(string $foundationType, BackupItem $item, string $reason): array
|
||||||
|
{
|
||||||
|
$sourceName = $item->resolvedDisplayName();
|
||||||
|
|
||||||
|
return $this->decisionEntry(
|
||||||
|
$foundationType,
|
||||||
|
$item->policy_identifier,
|
||||||
|
$sourceName,
|
||||||
|
'failed',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Services/Intune/FoundationSnapshotService.php
Normal file
121
app/Services/Intune/FoundationSnapshotService.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
|
|
||||||
|
class FoundationSnapshotService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GraphClientInterface $graphClient,
|
||||||
|
private readonly GraphContractRegistry $contracts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{items: array<int, array{source_id:string,display_name:?string,payload:array,metadata:array}>, failures: array<int, array{foundation_type:string,reason:string,status:int|string|null}>}
|
||||||
|
*/
|
||||||
|
public function fetchAll(Tenant $tenant, string $foundationType): array
|
||||||
|
{
|
||||||
|
$resource = $this->contracts->resourcePath($foundationType);
|
||||||
|
|
||||||
|
if (! $resource) {
|
||||||
|
return [
|
||||||
|
'items' => [],
|
||||||
|
'failures' => [[
|
||||||
|
'foundation_type' => $foundationType,
|
||||||
|
'reason' => 'Graph contract resource missing for foundation type.',
|
||||||
|
'status' => null,
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $this->contracts->get($foundationType);
|
||||||
|
$query = [];
|
||||||
|
|
||||||
|
if (! empty($contract['allowed_select']) && is_array($contract['allowed_select'])) {
|
||||||
|
$query['$select'] = $contract['allowed_select'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized = $this->contracts->sanitizeQuery($foundationType, $query);
|
||||||
|
$options = $tenant->graphOptions();
|
||||||
|
$items = [];
|
||||||
|
$failures = [];
|
||||||
|
$nextPath = $resource;
|
||||||
|
$useQuery = $sanitized['query'] ?? [];
|
||||||
|
|
||||||
|
while ($nextPath) {
|
||||||
|
$response = $this->graphClient->request('GET', $nextPath, $options + [
|
||||||
|
'query' => $useQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$failures[] = [
|
||||||
|
'foundation_type' => $foundationType,
|
||||||
|
'reason' => $response->meta['error_message'] ?? $response->warnings[0] ?? 'Graph request failed.',
|
||||||
|
'status' => $response->status,
|
||||||
|
];
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->data;
|
||||||
|
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
||||||
|
|
||||||
|
foreach ($pageItems as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceId = $item['id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($sourceId) || $sourceId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $item['displayName'] ?? $item['name'] ?? null;
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'display_name' => is_string($displayName) ? $displayName : null,
|
||||||
|
'payload' => $item,
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => is_string($displayName) ? $displayName : null,
|
||||||
|
'kind' => $foundationType,
|
||||||
|
'graph' => [
|
||||||
|
'resource' => $resource,
|
||||||
|
'apiVersion' => config('graph.version', 'beta'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextLink = $data['@odata.nextLink'] ?? null;
|
||||||
|
|
||||||
|
if (! $nextLink) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
||||||
|
$useQuery = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => $items,
|
||||||
|
'failures' => $failures,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripGraphBaseUrl(string $nextLink): string
|
||||||
|
{
|
||||||
|
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
||||||
|
.'/'.trim(config('graph.version', 'beta'), '/');
|
||||||
|
|
||||||
|
if (str_starts_with($nextLink, $base)) {
|
||||||
|
return ltrim(substr($nextLink, strlen($base)), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ltrim($nextLink, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ public function __construct(
|
|||||||
private readonly SnapshotValidator $snapshotValidator,
|
private readonly SnapshotValidator $snapshotValidator,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||||
|
private readonly FoundationMappingService $foundationMappingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,13 +41,19 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
|
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
|
||||||
return $items->map(function (BackupItem $item) use ($tenant) {
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
|
|
||||||
|
$foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? [];
|
||||||
|
|
||||||
|
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) {
|
||||||
$existing = Policy::query()
|
$existing = Policy::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('external_id', $item->policy_identifier)
|
->where('external_id', $item->policy_identifier)
|
||||||
->where('policy_type', $item->policy_type)
|
->where('policy_type', $item->policy_type)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'backup_item_id' => $item->id,
|
'backup_item_id' => $item->id,
|
||||||
'policy_identifier' => $item->policy_identifier,
|
'policy_identifier' => $item->policy_identifier,
|
||||||
@ -54,6 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
'platform' => $item->platform,
|
'platform' => $item->platform,
|
||||||
'action' => $existing ? 'update' : 'create',
|
'action' => $existing ? 'update' : 'create',
|
||||||
'conflict' => false,
|
'conflict' => false,
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
'validation_warning' => BackupItem::odataTypeWarning(
|
'validation_warning' => BackupItem::odataTypeWarning(
|
||||||
is_array($item->payload) ? $item->payload : [],
|
is_array($item->payload) ? $item->payload : [],
|
||||||
$item->policy_type,
|
$item->policy_type,
|
||||||
@ -61,6 +69,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
|
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
|
|
||||||
|
return array_merge($foundationPreview, $policyPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,6 +91,7 @@ public function execute(
|
|||||||
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
|
|
||||||
$restoreRun = RestoreRun::create([
|
$restoreRun = RestoreRun::create([
|
||||||
@ -115,10 +126,27 @@ public function execute(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = [];
|
$foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun);
|
||||||
$hardFailures = 0;
|
$foundationEntries = $foundationOutcome['entries'] ?? [];
|
||||||
|
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
|
||||||
|
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
|
||||||
|
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
|
||||||
|
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
if (! $dryRun) {
|
||||||
|
$this->auditFoundationMapping(
|
||||||
|
tenant: $tenant,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
entries: $foundationEntries,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $foundationEntries;
|
||||||
|
$hardFailures = $foundationFailures;
|
||||||
|
|
||||||
|
foreach ($policyItems as $item) {
|
||||||
$context = [
|
$context = [
|
||||||
'tenant' => $tenantIdentifier,
|
'tenant' => $tenantIdentifier,
|
||||||
'policy_type' => $item->policy_type,
|
'policy_type' => $item->policy_type,
|
||||||
@ -126,6 +154,18 @@ public function execute(
|
|||||||
'backup_item_id' => $item->id,
|
'backup_item_id' => $item->id,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||||
|
|
||||||
|
if ($restoreMode === 'preview-only') {
|
||||||
|
$results[] = $context + [
|
||||||
|
'status' => $dryRun ? 'dry_run' : 'skipped',
|
||||||
|
'reason' => 'preview_only',
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$odataValidation = BackupItem::validateODataType(
|
$odataValidation = BackupItem::validateODataType(
|
||||||
is_array($item->payload) ? $item->payload : [],
|
is_array($item->payload) ? $item->payload : [],
|
||||||
$item->policy_type,
|
$item->policy_type,
|
||||||
@ -148,7 +188,10 @@ public function execute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$results[] = $context + ['status' => 'dry_run'];
|
$results[] = $context + [
|
||||||
|
'status' => 'dry_run',
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -157,9 +200,12 @@ public function execute(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$originalPayload = is_array($item->payload) ? $item->payload : [];
|
$originalPayload = is_array($item->payload) ? $item->payload : [];
|
||||||
|
$originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping);
|
||||||
|
$mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
|
||||||
|
|
||||||
// sanitize high-level fields according to contract
|
// sanitize high-level fields according to contract
|
||||||
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
|
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
|
||||||
|
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
|
||||||
|
|
||||||
$graphOptions = [
|
$graphOptions = [
|
||||||
'tenant' => $tenantIdentifier,
|
'tenant' => $tenantIdentifier,
|
||||||
@ -300,6 +346,7 @@ public function execute(
|
|||||||
policyId: $assignmentPolicyId,
|
policyId: $assignmentPolicyId,
|
||||||
assignments: $item->assignments,
|
assignments: $item->assignments,
|
||||||
groupMapping: $groupMapping,
|
groupMapping: $groupMapping,
|
||||||
|
foundationMapping: $foundationMappingByType,
|
||||||
restoreRun: $restoreRun,
|
restoreRun: $restoreRun,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
@ -313,7 +360,10 @@ public function execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $context + ['status' => $itemStatus];
|
$result = $context + [
|
||||||
|
'status' => $itemStatus,
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
|
];
|
||||||
|
|
||||||
if ($settingsApply !== null) {
|
if ($settingsApply !== null) {
|
||||||
$result['settings_apply'] = $settingsApply;
|
$result['settings_apply'] = $settingsApply;
|
||||||
@ -366,8 +416,16 @@ public function execute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resultStatuses = collect($results)->pluck('status')->all();
|
$resultStatuses = collect($results)->pluck('status')->all();
|
||||||
$nonApplied = collect($resultStatuses)->filter(fn (string $status) => $status !== 'applied' && $status !== 'dry_run')->count();
|
$nonApplied = collect($resultStatuses)->filter(fn ($status) => is_string($status) && $status !== 'applied' && $status !== 'dry_run')->count();
|
||||||
$allHardFailed = count($results) > 0 && $hardFailures === count($results);
|
$foundationNonApplied = collect($foundationEntries)->filter(function (array $entry): bool {
|
||||||
|
$decision = $entry['decision'] ?? null;
|
||||||
|
|
||||||
|
return in_array($decision, ['failed', 'skipped'], true);
|
||||||
|
})->count();
|
||||||
|
|
||||||
|
$nonApplied += $foundationNonApplied;
|
||||||
|
$totalCount = count($results);
|
||||||
|
$allHardFailed = $totalCount > 0 && $hardFailures === $totalCount;
|
||||||
|
|
||||||
$status = $dryRun
|
$status = $dryRun
|
||||||
? 'previewed'
|
? 'previewed'
|
||||||
@ -384,7 +442,8 @@ public function execute(
|
|||||||
'metadata' => [
|
'metadata' => [
|
||||||
'failed' => $hardFailures,
|
'failed' => $hardFailures,
|
||||||
'non_applied' => $nonApplied,
|
'non_applied' => $nonApplied,
|
||||||
'total' => count($results),
|
'total' => $totalCount,
|
||||||
|
'foundations_skipped' => $foundationSkipped,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -409,6 +468,191 @@ public function execute(
|
|||||||
return $restoreRun->refresh();
|
return $restoreRun->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, BackupItem> $items
|
||||||
|
* @return array{0: Collection<int, BackupItem>, 1: Collection<int, BackupItem>}
|
||||||
|
*/
|
||||||
|
private function splitItems(Collection $items): array
|
||||||
|
{
|
||||||
|
$foundationItems = $items->filter(fn (BackupItem $item) => $item->isFoundation())->values();
|
||||||
|
$policyItems = $items->reject(fn (BackupItem $item) => $item->isFoundation())->values();
|
||||||
|
|
||||||
|
return [$foundationItems, $policyItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resolveTypeMeta(string $policyType): array
|
||||||
|
{
|
||||||
|
$types = array_merge(
|
||||||
|
config('tenantpilot.supported_policy_types', []),
|
||||||
|
config('tenantpilot.foundation_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($types as $typeConfig) {
|
||||||
|
if (($typeConfig['type'] ?? null) === $policyType) {
|
||||||
|
return $typeConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRestoreMode(string $policyType): string
|
||||||
|
{
|
||||||
|
$meta = $this->resolveTypeMeta($policyType);
|
||||||
|
$restore = $meta['restore'] ?? 'enabled';
|
||||||
|
|
||||||
|
if (! is_string($restore) || $restore === '') {
|
||||||
|
return 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entries
|
||||||
|
* @return array<string, array<string, string>>
|
||||||
|
*/
|
||||||
|
private function buildFoundationMappingByType(array $entries): array
|
||||||
|
{
|
||||||
|
$mapping = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $entry['type'] ?? null;
|
||||||
|
$sourceId = $entry['sourceId'] ?? null;
|
||||||
|
$targetId = $entry['targetId'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($type) || $type === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($sourceId) || $sourceId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($targetId) || $targetId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapping[$type][$sourceId] = $targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, string> $scopeTagMapping
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function applyScopeTagMapping(array $payload, array $scopeTagMapping): array
|
||||||
|
{
|
||||||
|
if ($scopeTagMapping === []) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roleScopeTagIds = $this->resolvePayloadArray($payload, ['roleScopeTagIds', 'RoleScopeTagIds']);
|
||||||
|
|
||||||
|
if ($roleScopeTagIds === null) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped = [];
|
||||||
|
|
||||||
|
foreach ($roleScopeTagIds as $id) {
|
||||||
|
if (is_string($id) || is_int($id)) {
|
||||||
|
$stringId = (string) $id;
|
||||||
|
$mapped[] = $scopeTagMapping[$stringId] ?? $stringId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mapped === []) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['roleScopeTagIds'] = array_values(array_unique($mapped));
|
||||||
|
unset($payload['RoleScopeTagIds']);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<int, mixed>|null $scopeTagIds
|
||||||
|
* @param array<string, string> $scopeTagMapping
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array
|
||||||
|
{
|
||||||
|
if ($scopeTagIds === null || $scopeTagMapping === []) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['roleScopeTagIds'] = array_values($scopeTagIds);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entries
|
||||||
|
*/
|
||||||
|
private function auditFoundationMapping(
|
||||||
|
Tenant $tenant,
|
||||||
|
RestoreRun $restoreRun,
|
||||||
|
array $entries,
|
||||||
|
?string $actorEmail,
|
||||||
|
?string $actorName
|
||||||
|
): void {
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = $entry['decision'] ?? 'mapped_existing';
|
||||||
|
$action = match ($decision) {
|
||||||
|
'created_copy' => 'restore.foundation.created_copy',
|
||||||
|
'created' => 'restore.foundation.created',
|
||||||
|
'failed' => 'restore.foundation.failed',
|
||||||
|
'skipped' => 'restore.foundation.skipped',
|
||||||
|
default => 'restore.foundation.mapped',
|
||||||
|
};
|
||||||
|
|
||||||
|
$status = match ($decision) {
|
||||||
|
'failed' => 'failed',
|
||||||
|
'skipped' => 'warning',
|
||||||
|
default => 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'type' => $entry['type'] ?? null,
|
||||||
|
'source_id' => $entry['sourceId'] ?? null,
|
||||||
|
'source_name' => $entry['sourceName'] ?? null,
|
||||||
|
'target_id' => $entry['targetId'] ?? null,
|
||||||
|
'target_name' => $entry['targetName'] ?? null,
|
||||||
|
'decision' => $decision,
|
||||||
|
'reason' => $entry['reason'] ?? null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $restoreRun->id,
|
||||||
|
status: $status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int>|null $selectedItemIds
|
* @param array<int>|null $selectedItemIds
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -39,10 +39,16 @@ public function getGrantedPermissions(Tenant $tenant): array
|
|||||||
* @param array<string, array{status:string,details?:array<string,mixed>|null}|string>|null $grantedStatuses
|
* @param array<string, array{status:string,details?:array<string,mixed>|null}|string>|null $grantedStatuses
|
||||||
* @param bool $persist Persist comparison results to tenant_permissions
|
* @param bool $persist Persist comparison results to tenant_permissions
|
||||||
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
||||||
|
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
||||||
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
|
* @return array{overall_status:string,permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>}
|
||||||
*/
|
*/
|
||||||
public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true, bool $liveCheck = false): array
|
public function compare(
|
||||||
{
|
Tenant $tenant,
|
||||||
|
?array $grantedStatuses = null,
|
||||||
|
bool $persist = true,
|
||||||
|
bool $liveCheck = false,
|
||||||
|
bool $useConfiguredStub = true
|
||||||
|
): array {
|
||||||
$required = $this->getRequiredPermissions();
|
$required = $this->getRequiredPermissions();
|
||||||
$liveCheckFailed = false;
|
$liveCheckFailed = false;
|
||||||
$liveCheckDetails = null;
|
$liveCheckDetails = null;
|
||||||
@ -58,8 +64,17 @@ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $pe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$storedStatuses = $this->getGrantedPermissions($tenant);
|
||||||
|
|
||||||
|
if (! $useConfiguredStub) {
|
||||||
|
$storedStatuses = $this->dropConfiguredStatuses($storedStatuses);
|
||||||
|
}
|
||||||
|
|
||||||
$granted = $this->normalizeGrantedStatuses(
|
$granted = $this->normalizeGrantedStatuses(
|
||||||
$grantedStatuses ?? array_replace_recursive($this->configuredGrantedStatuses(), $this->getGrantedPermissions($tenant))
|
$grantedStatuses ?? array_replace_recursive(
|
||||||
|
$useConfiguredStub && ! $liveCheck ? $this->configuredGrantedStatuses() : [],
|
||||||
|
$storedStatuses
|
||||||
|
)
|
||||||
);
|
);
|
||||||
$results = [];
|
$results = [];
|
||||||
$hasMissing = false;
|
$hasMissing = false;
|
||||||
@ -138,6 +153,23 @@ private function normalizeGrantedStatuses(array $granted): array
|
|||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?\Illuminate\Support\Carbon}> $granted
|
||||||
|
* @return array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?\Illuminate\Support\Carbon}>
|
||||||
|
*/
|
||||||
|
private function dropConfiguredStatuses(array $granted): array
|
||||||
|
{
|
||||||
|
foreach ($granted as $key => $value) {
|
||||||
|
$source = $value['details']['source'] ?? null;
|
||||||
|
|
||||||
|
if ($source === 'configured') {
|
||||||
|
unset($granted[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $granted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -197,5 +197,56 @@
|
|||||||
'id_field' => 'id',
|
'id_field' => 'id',
|
||||||
'hydration' => 'properties',
|
'hydration' => 'properties',
|
||||||
],
|
],
|
||||||
|
'assignmentFilter' => [
|
||||||
|
'resource' => 'deviceManagement/assignmentFilters',
|
||||||
|
'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', '@odata.type', 'roleScopeTagIds'],
|
||||||
|
'allowed_expand' => [],
|
||||||
|
'type_family' => [
|
||||||
|
'#microsoft.graph.deviceAndAppManagementAssignmentFilter',
|
||||||
|
],
|
||||||
|
'create_method' => 'POST',
|
||||||
|
'update_method' => 'PATCH',
|
||||||
|
'id_field' => 'id',
|
||||||
|
'hydration' => 'properties',
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'isBuiltIn',
|
||||||
|
'createdDateTime',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'roleScopeTag' => [
|
||||||
|
'resource' => 'deviceManagement/roleScopeTags',
|
||||||
|
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'isBuiltIn'],
|
||||||
|
'allowed_expand' => [],
|
||||||
|
'type_family' => [
|
||||||
|
'#microsoft.graph.roleScopeTag',
|
||||||
|
],
|
||||||
|
'create_method' => 'POST',
|
||||||
|
'update_method' => 'PATCH',
|
||||||
|
'id_field' => 'id',
|
||||||
|
'hydration' => 'properties',
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'isBuiltIn',
|
||||||
|
'createdDateTime',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'notificationMessageTemplate' => [
|
||||||
|
'resource' => 'deviceManagement/notificationMessageTemplates',
|
||||||
|
'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', '@odata.type', 'lastModifiedDateTime'],
|
||||||
|
'allowed_expand' => [],
|
||||||
|
'type_family' => [
|
||||||
|
'#microsoft.graph.notificationMessageTemplate',
|
||||||
|
],
|
||||||
|
'create_method' => 'POST',
|
||||||
|
'update_method' => 'PATCH',
|
||||||
|
'id_field' => 'id',
|
||||||
|
'hydration' => 'properties',
|
||||||
|
'update_strip_keys' => [
|
||||||
|
'localizedNotificationMessages',
|
||||||
|
'createdDateTime',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -62,6 +62,12 @@
|
|||||||
'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.',
|
'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.',
|
||||||
'features' => ['scope-tags', 'backup-metadata', 'assignments'],
|
'features' => ['scope-tags', 'backup-metadata', 'assignments'],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'DeviceManagementRBAC.ReadWrite.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Manage Intune RBAC scope tags for foundation backup and restore.',
|
||||||
|
'features' => ['scope-tags', 'foundations', 'backup', 'restore'],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'key' => 'Group.Read.All',
|
'key' => 'Group.Read.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
@ -94,7 +100,7 @@
|
|||||||
'Directory.Read.All',
|
'Directory.Read.All',
|
||||||
'User.Read',
|
'User.Read',
|
||||||
'DeviceManagementScripts.ReadWrite.All',
|
'DeviceManagementScripts.ReadWrite.All',
|
||||||
|
|
||||||
// Feature 004 - Assignments & Scope Tags (granted seit 2025-12-22):
|
// Feature 004 - Assignments & Scope Tags (granted seit 2025-12-22):
|
||||||
'DeviceManagementRBAC.Read.All', // Scope Tag Namen auflösen
|
'DeviceManagementRBAC.Read.All', // Scope Tag Namen auflösen
|
||||||
'Group.Read.All', // Group Namen für Assignments auflösen
|
'Group.Read.All', // Group Namen für Assignments auflösen
|
||||||
|
|||||||
@ -115,6 +115,39 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'foundation_types' => [
|
||||||
|
[
|
||||||
|
'type' => 'assignmentFilter',
|
||||||
|
'label' => 'Assignment Filter',
|
||||||
|
'category' => 'Foundations',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/assignmentFilters',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'low',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'roleScopeTag',
|
||||||
|
'label' => 'Scope Tag',
|
||||||
|
'category' => 'Foundations',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/roleScopeTags',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'low',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'notificationMessageTemplate',
|
||||||
|
'label' => 'Notification Message Template',
|
||||||
|
'category' => 'Foundations',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/notificationMessageTemplates',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'low',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'features' => [
|
'features' => [
|
||||||
'conditional_access' => true,
|
'conditional_access' => true,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,29 +1,90 @@
|
|||||||
@php
|
@php
|
||||||
$preview = $getState() ?? [];
|
$preview = $getState() ?? [];
|
||||||
|
$foundationItems = collect($preview)->filter(function ($item) {
|
||||||
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||||
|
});
|
||||||
|
$policyItems = collect($preview)->reject(function ($item) {
|
||||||
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||||
|
});
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (empty($preview))
|
@if (empty($preview))
|
||||||
<p class="text-sm text-gray-600">No preview available.</p>
|
<p class="text-sm text-gray-600">No preview available.</p>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-2">
|
<div class="space-y-4">
|
||||||
@foreach ($preview as $item)
|
@if ($foundationItems->isNotEmpty())
|
||||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
||||||
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
|
@foreach ($foundationItems as $item)
|
||||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
|
@php
|
||||||
{{ $item['action'] ?? 'action' }}
|
$decision = $item['decision'] ?? 'mapped_existing';
|
||||||
</span>
|
$decisionColor = match ($decision) {
|
||||||
</div>
|
'created' => 'text-green-700 bg-green-100 border-green-200',
|
||||||
<div class="mt-1 text-xs text-gray-600">
|
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
{{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }}
|
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
|
||||||
</div>
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
|
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
|
||||||
@if (! empty($item['validation_warning']))
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
|
};
|
||||||
{{ $item['validation_warning'] }}
|
@endphp
|
||||||
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||||
|
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
|
||||||
|
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}">
|
||||||
|
{{ $decision }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ $item['type'] ?? 'foundation' }}
|
||||||
|
</div>
|
||||||
|
@if (! empty($item['targetName']))
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
Target: {{ $item['targetName'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($item['reason']))
|
||||||
|
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
|
||||||
|
{{ $item['reason'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endif
|
||||||
|
|
||||||
|
@if ($policyItems->isNotEmpty())
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
||||||
|
@foreach ($policyItems as $item)
|
||||||
|
@php
|
||||||
|
$restoreMode = $item['restore_mode'] ?? null;
|
||||||
|
@endphp
|
||||||
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||||
|
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if ($restoreMode === 'preview-only')
|
||||||
|
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
|
||||||
|
preview-only
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
|
||||||
|
{{ $item['action'] ?? 'action' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($item['validation_warning']))
|
||||||
|
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
|
||||||
|
{{ $item['validation_warning'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -1,243 +1,308 @@
|
|||||||
@php
|
@php
|
||||||
$results = $getState() ?? [];
|
$results = $getState() ?? [];
|
||||||
|
$foundationItems = collect($results)->filter(function ($item) {
|
||||||
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||||
|
});
|
||||||
|
$policyItems = collect($results)->reject(function ($item) {
|
||||||
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||||
|
});
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (empty($results))
|
@if (empty($results))
|
||||||
<p class="text-sm text-gray-600">No results recorded.</p>
|
<p class="text-sm text-gray-600">No results recorded.</p>
|
||||||
@else
|
@else
|
||||||
@php
|
@php
|
||||||
$needsAttention = collect($results)->contains(function ($item) {
|
$needsAttention = $policyItems->contains(function ($item) {
|
||||||
$status = $item['status'] ?? null;
|
$status = $item['status'] ?? null;
|
||||||
|
|
||||||
return in_array($status, ['partial', 'manual_required'], true);
|
return in_array($status, ['partial', 'manual_required'], true);
|
||||||
});
|
});
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
|
@if ($foundationItems->isNotEmpty())
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
||||||
|
@foreach ($foundationItems as $item)
|
||||||
|
@php
|
||||||
|
$decision = $item['decision'] ?? 'mapped_existing';
|
||||||
|
$decisionColor = match ($decision) {
|
||||||
|
'created' => 'text-green-700 bg-green-100 border-green-200',
|
||||||
|
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
|
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
|
||||||
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
|
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
|
||||||
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||||
|
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
|
||||||
|
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}">
|
||||||
|
{{ $decision }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
{{ $item['type'] ?? 'foundation' }}
|
||||||
|
</div>
|
||||||
|
@if (! empty($item['targetName']))
|
||||||
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
|
Target: {{ $item['targetName'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($item['reason']))
|
||||||
|
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||||
|
{{ $item['reason'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($needsAttention)
|
@if ($needsAttention)
|
||||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
Some settings could not be applied automatically. Review the per-setting details below.
|
Some settings could not be applied automatically. Review the per-setting details below.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($results as $item)
|
|
||||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<div class="font-semibold text-gray-900">
|
|
||||||
{{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }}
|
|
||||||
<span class="ml-2 text-xs text-gray-500">{{ $item['policy_type'] ?? '' }}</span>
|
|
||||||
</div>
|
|
||||||
@php
|
|
||||||
$status = $item['status'] ?? 'unknown';
|
|
||||||
$statusColor = match ($status) {
|
|
||||||
'applied' => 'text-green-700 bg-green-100 border-green-200',
|
|
||||||
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
|
|
||||||
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
|
|
||||||
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
||||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
||||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
|
|
||||||
{{ $status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@php
|
@if ($policyItems->isNotEmpty())
|
||||||
$itemReason = $item['reason'] ?? null;
|
<div class="space-y-3">
|
||||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
||||||
@endphp
|
@foreach ($policyItems as $item)
|
||||||
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
<div class="flex items-center justify-between text-sm">
|
||||||
<div class="mt-2 text-sm text-gray-800">
|
<div class="font-semibold text-gray-900">
|
||||||
{{ $itemReason }}
|
{{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }}
|
||||||
</div>
|
<span class="ml-2 text-xs text-gray-500">{{ $item['policy_type'] ?? '' }}</span>
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
|
|
||||||
@php
|
|
||||||
$summary = $item['assignment_summary'];
|
|
||||||
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
|
|
||||||
$assignmentIssues = collect($assignmentOutcomes)
|
|
||||||
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
|
|
||||||
->values();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-gray-700">
|
|
||||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
|
||||||
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
|
||||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($assignmentIssues->isNotEmpty())
|
|
||||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
||||||
<summary class="cursor-pointer font-semibold">Assignment details</summary>
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
@foreach ($assignmentIssues as $outcome)
|
|
||||||
@php
|
|
||||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
|
||||||
$outcomeColor = match ($outcomeStatus) {
|
|
||||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
||||||
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
||||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
||||||
};
|
|
||||||
$assignmentGroupId = $outcome['group_id']
|
|
||||||
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="rounded border border-amber-200 bg-white p-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="font-semibold text-gray-900">
|
|
||||||
Assignment {{ $assignmentGroupId ?? 'unknown group' }}
|
|
||||||
</div>
|
|
||||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
|
||||||
{{ $outcomeStatus }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! empty($outcome['mapped_group_id']))
|
|
||||||
<div class="mt-1 text-[11px] text-gray-800">
|
|
||||||
Mapped to: {{ $outcome['mapped_group_id'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@php
|
|
||||||
$outcomeReason = $outcome['reason'] ?? null;
|
|
||||||
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
|
|
||||||
<div class="mt-1 text-[11px] text-gray-800">
|
|
||||||
{{ $outcomeReason }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
|
|
||||||
<div class="mt-1 text-[11px] text-amber-900">
|
|
||||||
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
|
|
||||||
@if (! empty($outcome['graph_error_code']))
|
|
||||||
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
@php
|
||||||
@endif
|
$status = $item['status'] ?? 'unknown';
|
||||||
@endif
|
$restoreMode = $item['restore_mode'] ?? null;
|
||||||
|
$statusColor = match ($status) {
|
||||||
|
'applied' => 'text-green-700 bg-green-100 border-green-200',
|
||||||
|
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
|
||||||
|
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
|
||||||
|
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
|
||||||
|
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if ($restoreMode === 'preview-only')
|
||||||
|
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
|
||||||
|
preview-only
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
|
||||||
|
{{ $status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (! empty($item['created_policy_id']))
|
@php
|
||||||
@php
|
$itemReason = $item['reason'] ?? null;
|
||||||
$createdMode = $item['created_policy_mode'] ?? null;
|
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||||
$createdMessage = $createdMode === 'metadata_only'
|
|
||||||
? 'New policy created (metadata only). Apply settings manually.'
|
|
||||||
: 'New policy created (manual cleanup required).';
|
|
||||||
@endphp
|
|
||||||
<div class="mt-2 text-xs text-amber-800">
|
|
||||||
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code']))
|
if ($itemReason === 'preview_only') {
|
||||||
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
$itemReason = 'Preview-only policy type; execution skipped.';
|
||||||
<div class="font-semibold">Graph error</div>
|
}
|
||||||
<div>{{ $item['graph_error_message'] ?? 'Unknown error' }}</div>
|
@endphp
|
||||||
@if (! empty($item['graph_error_code']))
|
|
||||||
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
||||||
|
<div class="mt-2 text-sm text-gray-800">
|
||||||
|
{{ $itemReason }}
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']))
|
|
||||||
<details class="mt-1">
|
|
||||||
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
|
||||||
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
|
|
||||||
@if (! empty($item['graph_request_id']))
|
|
||||||
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
|
||||||
@endif
|
|
||||||
@if (! empty($item['graph_client_request_id']))
|
|
||||||
<div>client-request-id: {{ $item['graph_client_request_id'] }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! empty($item['settings_apply']) && is_array($item['settings_apply']))
|
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
|
||||||
@php
|
@php
|
||||||
$apply = $item['settings_apply'];
|
$summary = $item['assignment_summary'];
|
||||||
$total = (int) ($apply['total'] ?? 0);
|
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
|
||||||
$applied = (int) ($apply['applied'] ?? 0);
|
$assignmentIssues = collect($assignmentOutcomes)
|
||||||
$failed = (int) ($apply['failed'] ?? 0);
|
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
|
||||||
$manual = (int) ($apply['manual_required'] ?? 0);
|
->values();
|
||||||
$issues = $apply['issues'] ?? [];
|
@endphp
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-gray-700">
|
<div class="mt-2 text-xs text-gray-700">
|
||||||
Settings applied: {{ $applied }}/{{ $total }}
|
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
||||||
@if ($failed > 0 || $manual > 0)
|
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
||||||
• {{ $failed }} failed • {{ $manual }} manual
|
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||||
@endif
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! empty($issues))
|
@if ($assignmentIssues->isNotEmpty())
|
||||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||||
<summary class="cursor-pointer font-semibold">Settings requiring attention</summary>
|
<summary class="cursor-pointer font-semibold">Assignment details</summary>
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@foreach ($issues as $issue)
|
@foreach ($assignmentIssues as $outcome)
|
||||||
@php
|
@php
|
||||||
$issueStatus = $issue['status'] ?? 'unknown';
|
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||||
$issueColor = match ($issueStatus) {
|
$outcomeColor = match ($outcomeStatus) {
|
||||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
};
|
};
|
||||||
@endphp
|
$assignmentGroupId = $outcome['group_id']
|
||||||
<div class="rounded border border-amber-200 bg-white p-2">
|
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
||||||
<div class="flex items-center justify-between">
|
@endphp
|
||||||
<div class="font-semibold text-gray-900">
|
|
||||||
Setting {{ $issue['setting_id'] ?? 'unknown' }}
|
|
||||||
</div>
|
|
||||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $issueColor }}">
|
|
||||||
{{ $issueStatus }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! empty($issue['reason']))
|
<div class="rounded border border-amber-200 bg-white p-2">
|
||||||
<div class="mt-1 text-[11px] text-gray-800">
|
<div class="flex items-center justify-between">
|
||||||
{{ $issue['reason'] }}
|
<div class="font-semibold text-gray-900">
|
||||||
</div>
|
Assignment {{ $assignmentGroupId ?? 'unknown group' }}
|
||||||
@endif
|
</div>
|
||||||
|
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
||||||
|
{{ $outcomeStatus }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code']))
|
@if (! empty($outcome['mapped_group_id']))
|
||||||
<div class="mt-1 text-[11px] text-amber-900">
|
<div class="mt-1 text-[11px] text-gray-800">
|
||||||
<div>{{ $issue['graph_error_message'] ?? 'Unknown error' }}</div>
|
Mapped to: {{ $outcome['mapped_group_id'] }}
|
||||||
@if (! empty($issue['graph_error_code']))
|
</div>
|
||||||
<div class="mt-0.5 text-amber-800">Code: {{ $issue['graph_error_code'] }}</div>
|
|
||||||
@endif
|
@endif
|
||||||
@if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id']))
|
|
||||||
<div class="mt-0.5 space-y-0.5 text-amber-800">
|
@php
|
||||||
@if (! empty($issue['graph_request_id']))
|
$outcomeReason = $outcome['reason'] ?? null;
|
||||||
<div>request-id: {{ $issue['graph_request_id'] }}</div>
|
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
|
||||||
@endif
|
@endphp
|
||||||
@if (! empty($issue['graph_client_request_id']))
|
|
||||||
<div>client-request-id: {{ $issue['graph_client_request_id'] }}</div>
|
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
|
||||||
|
<div class="mt-1 text-[11px] text-gray-800">
|
||||||
|
{{ $outcomeReason }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
|
||||||
|
<div class="mt-1 text-[11px] text-amber-900">
|
||||||
|
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||||
|
@if (! empty($outcome['graph_error_code']))
|
||||||
|
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
</details>
|
||||||
</div>
|
@endif
|
||||||
</details>
|
@endif
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! empty($item['platform']))
|
@if (! empty($item['created_policy_id']))
|
||||||
<div class="mt-2 text-[11px] text-gray-500">
|
@php
|
||||||
Platform: {{ $item['platform'] }}
|
$createdMode = $item['created_policy_mode'] ?? null;
|
||||||
|
$createdMessage = $createdMode === 'metadata_only'
|
||||||
|
? 'New policy created (metadata only). Apply settings manually.'
|
||||||
|
: 'New policy created (manual cleanup required).';
|
||||||
|
@endphp
|
||||||
|
<div class="mt-2 text-xs text-amber-800">
|
||||||
|
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code']))
|
||||||
|
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||||
|
<div class="font-semibold">Graph error</div>
|
||||||
|
<div>{{ $item['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||||
|
@if (! empty($item['graph_error_code']))
|
||||||
|
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']))
|
||||||
|
<details class="mt-1">
|
||||||
|
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
||||||
|
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
|
||||||
|
@if (! empty($item['graph_request_id']))
|
||||||
|
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($item['graph_client_request_id']))
|
||||||
|
<div>client-request-id: {{ $item['graph_client_request_id'] }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! empty($item['settings_apply']) && is_array($item['settings_apply']))
|
||||||
|
@php
|
||||||
|
$apply = $item['settings_apply'];
|
||||||
|
$total = (int) ($apply['total'] ?? 0);
|
||||||
|
$applied = (int) ($apply['applied'] ?? 0);
|
||||||
|
$failed = (int) ($apply['failed'] ?? 0);
|
||||||
|
$manual = (int) ($apply['manual_required'] ?? 0);
|
||||||
|
$issues = $apply['issues'] ?? [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="mt-2 text-xs text-gray-700">
|
||||||
|
Settings applied: {{ $applied }}/{{ $total }}
|
||||||
|
@if ($failed > 0 || $manual > 0)
|
||||||
|
• {{ $failed }} failed • {{ $manual }} manual
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($issues))
|
||||||
|
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||||
|
<summary class="cursor-pointer font-semibold">Settings requiring attention</summary>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
@foreach ($issues as $issue)
|
||||||
|
@php
|
||||||
|
$issueStatus = $issue['status'] ?? 'unknown';
|
||||||
|
$issueColor = match ($issueStatus) {
|
||||||
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
|
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<div class="rounded border border-amber-200 bg-white p-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-semibold text-gray-900">
|
||||||
|
Setting {{ $issue['setting_id'] ?? 'unknown' }}
|
||||||
|
</div>
|
||||||
|
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $issueColor }}">
|
||||||
|
{{ $issueStatus }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! empty($issue['reason']))
|
||||||
|
<div class="mt-1 text-[11px] text-gray-800">
|
||||||
|
{{ $issue['reason'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code']))
|
||||||
|
<div class="mt-1 text-[11px] text-amber-900">
|
||||||
|
<div>{{ $issue['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||||
|
@if (! empty($issue['graph_error_code']))
|
||||||
|
<div class="mt-0.5 text-amber-800">Code: {{ $issue['graph_error_code'] }}</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id']))
|
||||||
|
<div class="mt-0.5 space-y-0.5 text-amber-800">
|
||||||
|
@if (! empty($issue['graph_request_id']))
|
||||||
|
<div>request-id: {{ $issue['graph_request_id'] }}</div>
|
||||||
|
@endif
|
||||||
|
@if (! empty($issue['graph_client_request_id']))
|
||||||
|
<div>client-request-id: {{ $issue['graph_client_request_id'] }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (! empty($item['platform']))
|
||||||
|
<div class="mt-2 text-[11px] text-gray-500">
|
||||||
|
Platform: {{ $item['platform'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: SoT Foundations & Assignments
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2025-12-25
|
||||||
|
**Feature**: [specs/006-sot-foundations-assignments/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
|
|
||||||
|
- Validation pass: Spec contains no [NEEDS CLARIFICATION] markers and scopes CA restore to preview-only until dependency mapping exists.
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "assignment-apply-request.schema.json",
|
||||||
|
"title": "AssignmentApplyRequest",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["assignments"],
|
||||||
|
"properties": {
|
||||||
|
"assignments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["target"],
|
||||||
|
"properties": {
|
||||||
|
"target": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["@odata.type"],
|
||||||
|
"properties": {
|
||||||
|
"@odata.type": { "type": "string" },
|
||||||
|
"groupId": { "type": "string" },
|
||||||
|
"deviceAndAppManagementAssignmentFilterId": { "type": "string" },
|
||||||
|
"deviceAndAppManagementAssignmentFilterType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["include", "exclude", "Include", "Exclude"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "foundation-snapshot.schema.json",
|
||||||
|
"title": "FoundationSnapshot",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "sourceId", "payload"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["assignmentFilter", "roleScopeTag", "notificationMessageTemplate"]
|
||||||
|
},
|
||||||
|
"sourceId": { "type": "string" },
|
||||||
|
"displayName": { "type": "string" },
|
||||||
|
"payload": { "type": "object" },
|
||||||
|
"metadata": { "type": ["object", "null"] }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "restore-mapping-report.schema.json",
|
||||||
|
"title": "RestoreMappingReport",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["foundations"],
|
||||||
|
"properties": {
|
||||||
|
"foundations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "sourceId", "decision"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["assignmentFilter", "roleScopeTag", "notificationMessageTemplate"]
|
||||||
|
},
|
||||||
|
"sourceId": { "type": "string" },
|
||||||
|
"sourceName": { "type": "string" },
|
||||||
|
"decision": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mapped_existing", "created", "created_copy", "skipped", "failed"]
|
||||||
|
},
|
||||||
|
"targetId": { "type": ["string", "null"] },
|
||||||
|
"targetName": { "type": ["string", "null"] },
|
||||||
|
"reason": { "type": ["string", "null"] }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
111
specs/006-sot-foundations-assignments/data-model.md
Normal file
111
specs/006-sot-foundations-assignments/data-model.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# Data Model: SoT Foundations & Assignments (006)
|
||||||
|
|
||||||
|
This feature reuses existing snapshot and restore run entities, and introduces a consistent JSON “mapping + decisions” report.
|
||||||
|
|
||||||
|
## Existing Entities (today)
|
||||||
|
|
||||||
|
### BackupSet
|
||||||
|
|
||||||
|
- Purpose: Groups a point-in-time capture for a tenant.
|
||||||
|
- Relationships: hasMany `BackupItem`.
|
||||||
|
|
||||||
|
### BackupItem
|
||||||
|
|
||||||
|
- Purpose: Stores an immutable snapshot item.
|
||||||
|
- Key fields (relevant):
|
||||||
|
- `tenant_id`, `backup_set_id`
|
||||||
|
- `policy_id` (nullable)
|
||||||
|
- `policy_identifier` (Graph id)
|
||||||
|
- `policy_type` (logical type)
|
||||||
|
- `payload` (raw JSON)
|
||||||
|
- `metadata` (normalized JSON)
|
||||||
|
|
||||||
|
### RestoreRun
|
||||||
|
|
||||||
|
- Purpose: Tracks restore preview/execution lifecycle.
|
||||||
|
- Key fields (relevant):
|
||||||
|
- `is_dry_run`
|
||||||
|
- `requested_items` (selection)
|
||||||
|
- `preview` (dry-run decision report)
|
||||||
|
- `results` (execution report)
|
||||||
|
- `metadata` (extra structured info)
|
||||||
|
|
||||||
|
## New / Extended Concepts (this feature)
|
||||||
|
|
||||||
|
### FoundationSnapshot (logical concept)
|
||||||
|
|
||||||
|
Represented as a `backup_items` row.
|
||||||
|
|
||||||
|
- `policy_type` (new keys):
|
||||||
|
- `assignmentFilter`
|
||||||
|
- `roleScopeTag`
|
||||||
|
- `notificationMessageTemplate`
|
||||||
|
- `policy_identifier`: source Graph `id`
|
||||||
|
- `policy_id`: `null`
|
||||||
|
- `payload`: raw Graph resource JSON
|
||||||
|
- `metadata` (proposed, shape):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"displayName": "...",
|
||||||
|
"kind": "assignmentFilter|roleScopeTag|notificationMessageTemplate",
|
||||||
|
"graph": {
|
||||||
|
"resource": "deviceManagement/assignmentFilters",
|
||||||
|
"apiVersion": "v1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RestoreMappingReport (logical concept)
|
||||||
|
|
||||||
|
Stored within `restore_runs.preview`/`restore_runs.results`.
|
||||||
|
|
||||||
|
- `mappings.foundations[]` (proposed shape):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "assignmentFilter",
|
||||||
|
"sourceId": "<old-guid>",
|
||||||
|
"sourceName": "Filter A",
|
||||||
|
"decision": "mapped_existing|created|created_copy|failed",
|
||||||
|
"targetId": "<new-guid>",
|
||||||
|
"targetName": "Filter A (Copy)",
|
||||||
|
"reason": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AssignmentDecisionReport (logical concept)
|
||||||
|
|
||||||
|
Stored within `restore_runs.preview`/`restore_runs.results`.
|
||||||
|
|
||||||
|
- `assignments[]` entries (proposed shape):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"policyType": "settingsCatalogPolicy",
|
||||||
|
"sourcePolicyId": "...",
|
||||||
|
"targetPolicyId": "...",
|
||||||
|
"decision": "applied|skipped|failed",
|
||||||
|
"reason": "missing_filter_mapping|missing_group_mapping|preview_only|graph_error",
|
||||||
|
"details": {
|
||||||
|
"sourceAssignmentCount": 3,
|
||||||
|
"appliedAssignmentCount": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationships / Flow
|
||||||
|
|
||||||
|
- `BackupSet` contains both “policy snapshots” and “foundation snapshots” as `BackupItem` rows.
|
||||||
|
- `RestoreRun` consumes a `BackupSet` and produces:
|
||||||
|
- foundation mapping report
|
||||||
|
- policy restore decisions
|
||||||
|
- assignment application decisions
|
||||||
|
|
||||||
|
## Validation & State Transitions
|
||||||
|
|
||||||
|
- Restore execution is single-writer per tenant (existing safety requirement FR-009).
|
||||||
|
- Restore behavior:
|
||||||
|
- Preview (`is_dry_run=true`): builds mapping/decisions, **no Graph writes**.
|
||||||
|
- Execute (`is_dry_run=false`): creates missing foundations, restores policies, applies assignments when safe.
|
||||||
|
- Conditional Access entries are always recorded as preview-only/skipped in execute.
|
||||||
108
specs/006-sot-foundations-assignments/plan.md
Normal file
108
specs/006-sot-foundations-assignments/plan.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# Implementation Plan: SoT Foundations & Assignments
|
||||||
|
|
||||||
|
**Branch**: `006-sot-foundations-assignments` | **Date**: 2025-12-25 | **Spec**: ./spec.md
|
||||||
|
**Input**: Feature specification from `/specs/006-sot-foundations-assignments/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement foundations-first backup/restore for Intune dependencies (Assignment Filters, Scope Tags, Notification Message Templates) and extend restore to be assignment-aware using a deterministic old→new ID mapping report. Conditional Access remains preview-only (never executed) until its dependency mapping is supported.
|
||||||
|
|
||||||
|
Phase outputs:
|
||||||
|
- Phase 0 research: `./research.md`
|
||||||
|
- Phase 1 design: `./data-model.md`, `./contracts/`, `./quickstart.md`
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||||
|
for the project. The structure here is presented in advisory capacity to guide
|
||||||
|
the iteration process.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3, Microsoft Graph (custom client abstraction)
|
||||||
|
**Storage**: PostgreSQL (JSONB payload storage for snapshots)
|
||||||
|
**Testing**: Pest v4 + PHPUnit 12
|
||||||
|
**Target Platform**: Docker/Sail locally; container deploy via Dokploy
|
||||||
|
**Project Type**: Web application (Laravel backend + Filament admin UI)
|
||||||
|
**Performance Goals**: Restore preview for ~100 items in <2 minutes (SC-003); handle Graph paging and throttling safely
|
||||||
|
**Constraints**: Restore must be defensive: no deletions; skip unsafe assignments; produce audit/report; respect Graph throttling
|
||||||
|
**Scale/Scope**: Tenants with large policy inventories; focus on foundational object types + assignment application for already-supported policy types
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
The constitution at `.specify/memory/constitution.md` is currently an unfilled template (no ratified gates). For this feature, adopt the repo’s documented operating rules as gates:
|
||||||
|
|
||||||
|
- **Sail-first** local dev/test commands.
|
||||||
|
- **SpecKit Gate Rule**: code changes must be accompanied by `specs/006-sot-foundations-assignments/` updates.
|
||||||
|
- **Testing is required**: every behavioral change covered by Pest tests.
|
||||||
|
- **Safety**: restore never deletes; assignments only applied when mapped; CA stays preview-only.
|
||||||
|
- **Auditability**: restore/backup outcomes recorded and tenant-scoped.
|
||||||
|
|
||||||
|
If the team later ratifies a real constitution, re-map these gates accordingly.
|
||||||
|
|
||||||
|
**Post-Phase 1 re-check**: Pass (no violations introduced by the Phase 1 design artifacts).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/[###-feature]/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ └── Resources/
|
||||||
|
├── Jobs/
|
||||||
|
├── Models/
|
||||||
|
│ ├── BackupItem.php
|
||||||
|
│ ├── BackupSet.php
|
||||||
|
│ └── RestoreRun.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Graph/
|
||||||
|
│ └── Intune/
|
||||||
|
└── Support/
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── graph_contracts.php
|
||||||
|
└── tenantpilot.php
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── migrations/
|
||||||
|
└── factories/
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
└── Unit/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Implement as incremental additions to existing Laravel services/models/jobs, with Filament UI using the existing Backup/Restore flows.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
55
specs/006-sot-foundations-assignments/quickstart.md
Normal file
55
specs/006-sot-foundations-assignments/quickstart.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Quickstart: SoT Foundations & Assignments (006)
|
||||||
|
|
||||||
|
This is a developer/operator checklist to validate foundations-first restore and assignment-aware restore.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Local dev via Sail.
|
||||||
|
- A tenant configured for Graph access with sufficient permissions for:
|
||||||
|
- Assignment filters: `DeviceManagementConfiguration.ReadWrite.All`
|
||||||
|
- Scope tags: `DeviceManagementRBAC.ReadWrite.All`
|
||||||
|
- Notification templates: `DeviceManagementServiceConfig.ReadWrite.All`
|
||||||
|
|
||||||
|
## Scenario A: Foundations backup + restore
|
||||||
|
|
||||||
|
1. In a test tenant, create:
|
||||||
|
- 1–2 assignment filters
|
||||||
|
- 1–2 scope tags (non-built-in)
|
||||||
|
- 1 notification message template
|
||||||
|
2. Run a sync + backup via the app’s existing workflow.
|
||||||
|
3. In the target tenant, ensure those objects do not exist.
|
||||||
|
4. Run restore in **preview**:
|
||||||
|
- Verify preview includes a “Foundations” section.
|
||||||
|
- Verify it reports old→new mapping decisions.
|
||||||
|
5. Run restore in **execute**:
|
||||||
|
- Verify missing foundations are created.
|
||||||
|
- Verify collisions result in “created_copy” behavior (if you intentionally create same-named items beforehand).
|
||||||
|
|
||||||
|
## Scenario B: Assignment-aware restore
|
||||||
|
|
||||||
|
1. Create a policy that has assignments:
|
||||||
|
- Group targeting
|
||||||
|
- Assignment filters (include/exclude)
|
||||||
|
- Scope tags where applicable
|
||||||
|
2. Back up the tenant.
|
||||||
|
3. Restore into a target tenant where:
|
||||||
|
- some foundations exist
|
||||||
|
- some foundations are missing
|
||||||
|
4. Run restore preview:
|
||||||
|
- Verify assignments are marked “applied” only when mappings exist.
|
||||||
|
- Verify unsafe assignments are “skipped” with explicit reasons (no broad targeting).
|
||||||
|
5. Run restore execute:
|
||||||
|
- Verify the policy is restored.
|
||||||
|
- Verify assignment application uses the mapping.
|
||||||
|
|
||||||
|
## Scenario C: Conditional Access preview-only
|
||||||
|
|
||||||
|
1. Ensure the backup contains at least one Conditional Access policy.
|
||||||
|
2. Run restore preview:
|
||||||
|
- Verify CA items appear with a clear preview-only marker.
|
||||||
|
3. Run restore execute:
|
||||||
|
- Verify CA changes are not applied and are recorded as skipped/preview-only.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If UI changes don’t appear, run the project’s dev/build pipeline (`composer run dev` / `pnpm dev`) according to existing repo conventions.
|
||||||
86
specs/006-sot-foundations-assignments/research.md
Normal file
86
specs/006-sot-foundations-assignments/research.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Research: SoT Foundations & Assignments (006)
|
||||||
|
|
||||||
|
This document resolves planning unknowns and records decisions for implementing foundations-first backup/restore and assignment-aware restore.
|
||||||
|
|
||||||
|
## Decision: Foundation object endpoints and permissions
|
||||||
|
|
||||||
|
- **Decision**: Implement “foundation” backup/restore for:
|
||||||
|
- Assignment Filters via `deviceManagement/assignmentFilters` (permission: `DeviceManagementConfiguration.ReadWrite.All`).
|
||||||
|
- Scope Tags via `deviceManagement/roleScopeTags` (permission: `DeviceManagementRBAC.ReadWrite.All`).
|
||||||
|
- Notification Message Templates via `deviceManagement/notificationMessageTemplates` (permission: `DeviceManagementServiceConfig.ReadWrite.All`, with `localizedNotificationMessages` treated as a future enhancement).
|
||||||
|
- **Rationale**: These are explicitly called out as SoT foundations and appear as dependencies in the IntuneManagement reference implementation.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Treat foundations as “manual prerequisites” only (no backup/restore) → rejected because it blocks safe assignment restore.
|
||||||
|
- Store only names (no full payload) → rejected because restore needs full object definitions.
|
||||||
|
|
||||||
|
## Decision: Assignment apply mechanism (Graph)
|
||||||
|
|
||||||
|
- **Decision**: Apply assignments using a per-resource `.../{id}/assign` Graph action (default), with request body shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"target": {
|
||||||
|
"@odata.type": "...",
|
||||||
|
"groupId": "...",
|
||||||
|
"deviceAndAppManagementAssignmentFilterId": "...",
|
||||||
|
"deviceAndAppManagementAssignmentFilterType": "Include|Exclude"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and support type-specific overrides if needed.
|
||||||
|
- **Rationale**: Matches the IntuneManagement import approach and aligns with SoT “apply assignments after foundations exist”.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- PATCH the resource with an `assignments` property → rejected because many Intune resources do not support assignment updates via PATCH.
|
||||||
|
- Only restore object payloads, never assignments → rejected (SoT requires assignment-aware restore).
|
||||||
|
|
||||||
|
## Decision: Mapping strategy (deterministic, safe)
|
||||||
|
|
||||||
|
- **Decision**: Produce and persist an “old → new” mapping for foundation objects by matching primarily on `displayName` (or name-equivalent), with collision handling:
|
||||||
|
- If a unique match exists in the target tenant by name: reuse (map old → existing).
|
||||||
|
- If no match exists: create (map old → created).
|
||||||
|
- If multiple matches exist: create a copy with a predictable suffix and record “created_copy” in the report.
|
||||||
|
- **Rationale**: SoT requires determinism and auditability; mapping by opaque IDs is impossible across tenants.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Always create new objects regardless of matches → rejected due to duplication and name collision risk.
|
||||||
|
- Hash-based matching (normalize and compare multiple fields) → deferred; start with name-based plus explicit collision handling.
|
||||||
|
|
||||||
|
## Decision: Where to store mappings and restore decision report
|
||||||
|
|
||||||
|
- **Decision**: Store mapping + decisions in `restore_runs.preview` (dry-run) and `restore_runs.results` (execute), optionally mirrored into `restore_runs.metadata` for fast access.
|
||||||
|
- **Rationale**: The schema already supports JSON `preview`/`results`; this keeps the first iteration simple and audit-friendly.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Dedicated `restore_mappings` table → deferred until querying/reporting requirements demand it.
|
||||||
|
|
||||||
|
## Decision: How to represent foundation snapshots in storage
|
||||||
|
|
||||||
|
- **Decision**: Store foundation snapshots as `backup_items` rows with:
|
||||||
|
- `policy_id = null`
|
||||||
|
- `policy_type` set to a dedicated type key (e.g. `assignmentFilter`, `roleScopeTag`, `notificationMessageTemplate`)
|
||||||
|
- `policy_identifier` set to the Graph object `id`
|
||||||
|
- `payload` containing the raw Graph resource
|
||||||
|
- `metadata` containing normalized identifiers used for matching (e.g. `displayName`).
|
||||||
|
- **Rationale**: `backup_items.policy_id` is nullable; reusing the same snapshot container avoids schema churn.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- New “foundation_snapshots” table → rejected for MVP due to extra migrations and duplication.
|
||||||
|
|
||||||
|
## Decision: Conditional Access restore behavior (safety)
|
||||||
|
|
||||||
|
- **Decision**: Keep Conditional Access restore as **preview-only**, even in execute mode.
|
||||||
|
- **Rationale**: CA depends on identity objects (e.g., named locations) and is security-critical; SoT explicitly allows preview-first for risky items.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Allow CA restore with best-effort group/user mapping → rejected as too risky without complete dependency mapping.
|
||||||
|
|
||||||
|
## Decision: Scope for assignment-aware restore (initial)
|
||||||
|
|
||||||
|
- **Decision**: Apply assignment mapping for existing supported configuration objects (policy types already in `config/tenantpilot.php`), focusing first on targets that include:
|
||||||
|
- group targeting (`groupId`)
|
||||||
|
- assignment filters (`deviceAndAppManagementAssignmentFilterId`/Type)
|
||||||
|
- role scope tags (`roleScopeTagIds`) where applicable.
|
||||||
|
- **Rationale**: Incrementally delivers value without requiring support for all object classes in SoT.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Expand to named locations / terms of use / authentication strengths immediately → deferred (separate dependency set).
|
||||||
92
specs/006-sot-foundations-assignments/spec.md
Normal file
92
specs/006-sot-foundations-assignments/spec.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# Feature Specification: SoT Foundations & Assignments
|
||||||
|
|
||||||
|
**Feature Branch**: `006-sot-foundations-assignments`
|
||||||
|
**Created**: 2025-12-25
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "SoT Foundations & Assignments: implement backup/restore foundations (assignment filters, scope tags, notification templates) and add assignment-aware backup/restore pipeline with ID mapping for core Intune objects; keep Conditional Access restore preview-only until named locations/mapping exist."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Restore Foundations First (Priority: P1)
|
||||||
|
|
||||||
|
As an admin, I want to back up and restore the core "foundation" objects that other configurations depend on (assignment filters, scope tags, and compliance notification templates), so that later restores can reliably re-apply assignments and dependencies.
|
||||||
|
|
||||||
|
**Why this priority**: Without these foundations, restores either fail or must skip assignments/dependencies, which reduces trust and makes outcomes unpredictable.
|
||||||
|
|
||||||
|
**Independent Test**: In a test tenant with at least one filter, one scope tag, and one notification template: create a backup snapshot, then restore into a tenant where they are missing. Verify that the restored objects exist and that a mapping from old IDs to new IDs is produced.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant with assignment filters, **When** a backup is created and later restored into a tenant missing those filters, **Then** missing filters are created and the restore reports the old→new identifier mapping.
|
||||||
|
2. **Given** a tenant with scope tags, **When** a restore runs, **Then** scope tags are restored before any dependent objects are applied.
|
||||||
|
3. **Given** a tenant with compliance notification templates, **When** a restore runs, **Then** templates are restored before applying compliance policy scheduled actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Apply Assignments Safely (Priority: P2)
|
||||||
|
|
||||||
|
As an admin, I want restores to apply assignments for supported configuration objects using the foundation mappings, so that a restore reproduces intended targeting while staying safe and auditable.
|
||||||
|
|
||||||
|
**Why this priority**: Restoring payloads without assignments is incomplete; restoring assignments without safe mapping can be dangerous.
|
||||||
|
|
||||||
|
**Independent Test**: Restore a small set of supported configurations that include assignments with filters and scope tags. Verify that assignments are applied when mappings exist, and skipped with a clear reason when mappings are missing.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a configuration object whose assignments reference filters/scope tags that exist (or can be mapped), **When** restore executes, **Then** assignments are applied and reported as applied.
|
||||||
|
2. **Given** a configuration object whose assignments reference a missing dependency (e.g., an unknown filter), **When** restore executes, **Then** the assignment is skipped (not broadly applied) and a human-readable reason is recorded.
|
||||||
|
3. **Given** an object restore with name collisions, **When** the system cannot unambiguously match a target, **Then** it creates a copy with a predictable suffix and records this decision in the restore report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Conditional Access Stays Preview-Only (Priority: P3)
|
||||||
|
|
||||||
|
As an admin, I want to preview Conditional Access (CA) policies and their dependencies, but I do not want CA restore to execute automatically until dependency mapping is supported.
|
||||||
|
|
||||||
|
**Why this priority**: CA is security-critical and often depends on other objects (like named locations) and identity references. A preview still delivers value without risking outages.
|
||||||
|
|
||||||
|
**Independent Test**: Include CA policies in a backup and run restore in "preview" mode. Verify preview shows intended actions and highlights missing dependencies, while execute mode does not apply CA changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a backup containing CA policies, **When** a restore preview is generated, **Then** CA items appear in preview with a clear "preview-only" indicator.
|
||||||
|
2. **Given** a restore execution (non-dry-run), **When** CA items are included, **Then** the system does not apply CA changes and records them as preview-only/skipped.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Missing permissions: backup/restore continues for other object types and clearly reports which categories failed due to permissions.
|
||||||
|
- Name collisions: multiple objects share the same display name; system must avoid ambiguous updates.
|
||||||
|
- Missing identity references: group/user references cannot be resolved; system must skip the assignment and report.
|
||||||
|
- Large tenants: operations must cope with pagination and partial failures without losing auditability.
|
||||||
|
- Throttling/transient failures: system retries safely and produces a final report if some items could not be processed.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST support backup and restore of foundation objects: assignment filters, scope tags, and compliance notification templates.
|
||||||
|
- **FR-002**: System MUST restore foundation objects before applying any dependent configurations.
|
||||||
|
- **FR-003**: System MUST produce an identifier mapping report (old→new) for restored foundation objects.
|
||||||
|
- **FR-004**: System MUST apply assignments for supported configurations using the identifier mapping.
|
||||||
|
- **FR-005**: System MUST skip assignments that cannot be safely mapped (e.g., missing dependencies) and MUST record a clear skip reason.
|
||||||
|
- **FR-006**: System MUST be able to run in preview mode that produces the same decision report as execute mode, without making changes.
|
||||||
|
- **FR-007**: System MUST NOT delete objects in the target tenant as part of restore.
|
||||||
|
- **FR-008**: System MUST record an audit trail for backup and restore actions, including outcomes, partial failures, and skipped items.
|
||||||
|
- **FR-009**: System MUST prevent conflicting simultaneous restore executions for the same tenant (single-writer safety).
|
||||||
|
- **FR-010**: System MUST keep Conditional Access restore as preview-only until dependency mapping for CA is supported.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Foundation Object Snapshot**: A captured representation of an assignment filter, scope tag, or notification template.
|
||||||
|
- **Assignment Snapshot**: Captured targeting rules associated with a configuration object.
|
||||||
|
- **Restore Mapping**: A mapping of source identifiers to newly created target identifiers.
|
||||||
|
- **Restore Report**: A structured outcome summary containing applied items, skipped items, reasons, and any created copies.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In a tenant with at least 10 foundation objects, a full foundations restore completes with ≥ 99% of items either applied or explicitly skipped with a reason.
|
||||||
|
- **SC-002**: For supported configuration objects with assignments, ≥ 95% of assignments are either applied correctly or skipped with a clear reason (no silent failures).
|
||||||
|
- **SC-003**: Restore preview generation for 100 selected items completes in under 2 minutes in a typical admin environment.
|
||||||
|
- **SC-004**: Admins can complete a restore workflow (preview → execute) with no ambiguous outcomes: every selected item ends in Applied / Created Copy / Skipped / Failed with a recorded reason.
|
||||||
98
specs/006-sot-foundations-assignments/tasks.md
Normal file
98
specs/006-sot-foundations-assignments/tasks.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# Tasks: SoT Foundations & Assignments (006)
|
||||||
|
|
||||||
|
**Branch**: `feat/006-sot-foundations-assignments` | **Date**: 2025-12-25
|
||||||
|
**Input**: [spec.md](./spec.md), [plan.md](./plan.md), [data-model.md](./data-model.md), [research.md](./research.md), [contracts](./contracts/)
|
||||||
|
|
||||||
|
## Task Format
|
||||||
|
|
||||||
|
- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete
|
||||||
|
- **Task ID**: Sequential T001, T002, T003...
|
||||||
|
- **[P] marker**: Task can run in parallel (different files, no blocking dependencies)
|
||||||
|
- **[Story] label**: User story tag (US1, US2, US3...)
|
||||||
|
- **File path**: Always include exact file path in description
|
||||||
|
|
||||||
|
## Phase 1: Foundation Registry and Permissions
|
||||||
|
|
||||||
|
**Purpose**: Define foundation object types and ensure Graph contracts and permissions exist.
|
||||||
|
|
||||||
|
- [x] T001 [P] Add foundation type registry in `config/tenantpilot.php` (assignmentFilter, roleScopeTag, notificationMessageTemplate) with label/category/backup/restore/risk metadata.
|
||||||
|
- [x] T002 [P] Extend `config/graph_contracts.php` with foundation contracts (resource, create/update methods, id_field, allowed_select, type_family).
|
||||||
|
- [x] T003 [P] Extend `config/intune_permissions.php` to include foundation permissions (DeviceManagementRBAC.ReadWrite.All and any missing read/write scopes for filters/templates).
|
||||||
|
- [x] T004 Update type metadata helpers to include foundation types in `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`, and `app/Filament/Resources/RestoreRunResource.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation types and permissions defined and discoverable by UI helpers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundations Backup Capture
|
||||||
|
|
||||||
|
**Purpose**: Capture assignment filters, scope tags, and notification templates into backup sets.
|
||||||
|
|
||||||
|
- [x] T005 Create `app/Services/Intune/FoundationSnapshotService.php` to list and fetch foundation objects with Graph paging, normalized metadata, and fail-soft behavior.
|
||||||
|
- [x] T006 Extend `app/Services/Intune/BackupService.php` to capture foundation snapshots into `backup_items` (policy_id null, policy_type set, policy_identifier = source id, metadata includes displayName).
|
||||||
|
- [x] T007 Add a UI action/toggle to include foundations when adding to a backup set in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
|
||||||
|
- [x] T008 Add foundation display helpers on `app/Models/BackupItem.php` (e.g., isFoundation, foundationDisplayName) and use them in `BackupItemsRelationManager`.
|
||||||
|
|
||||||
|
**Checkpoint**: Foundations can be captured and displayed alongside policy backup items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Foundations Restore and Mapping
|
||||||
|
|
||||||
|
**Purpose**: Restore foundations first and persist deterministic old to new mappings.
|
||||||
|
|
||||||
|
- [x] T009 Create `app/Services/Intune/FoundationMappingService.php` to match by displayName, handle collisions, and emit report entries matching `contracts/restore-mapping-report.schema.json`.
|
||||||
|
- [x] T010 Extend `app/Services/Intune/RestoreService.php` to run foundation restore first, build preview mapping (dry-run), and persist mapping results in `restore_runs.preview` and `restore_runs.results`.
|
||||||
|
- [x] T011 Add audit events for foundation mapping decisions and failures in `app/Services/Intune/RestoreService.php`.
|
||||||
|
- [x] T012 Render foundation mapping in restore UI views: `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Restore preview and execute include a foundation mapping section with deterministic decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Assignment-Aware Restore
|
||||||
|
|
||||||
|
**Purpose**: Apply assignments only when foundation mappings exist and record clear skip reasons.
|
||||||
|
|
||||||
|
- [x] T013 Extend `app/Services/AssignmentRestoreService.php` to map assignment filter IDs via the foundation mapping; skip and record reasons when mappings are missing.
|
||||||
|
- [x] T014 Update `app/Services/Intune/RestoreService.php` to pass foundation mappings into assignment restore and apply scope tag mapping when restoring policies.
|
||||||
|
- [x] T015 Add mapping context to assignment audit logs in `app/Services/AssignmentRestoreService.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Assignments are applied safely with explicit skip reasons and audit coverage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Conditional Access Preview-Only Enforcement
|
||||||
|
|
||||||
|
**Purpose**: Keep CA restore preview-only even in execute mode.
|
||||||
|
|
||||||
|
- [x] T016 Update `app/Services/Intune/RestoreService.php` to prevent CA execution (status skipped, reason preview_only) while keeping preview output.
|
||||||
|
- [x] T017 Update restore UI to surface CA preview-only status in `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: CA items never execute; preview clearly signals preview-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Tests and Verification
|
||||||
|
|
||||||
|
**Purpose**: Ensure all new behavior is covered by Pest tests and formatting is clean.
|
||||||
|
|
||||||
|
- [x] T018 [P] Add unit tests for FoundationMappingService in `tests/Unit/FoundationMappingServiceTest.php`.
|
||||||
|
- [x] T019 [P] Add unit tests for FoundationSnapshotService in `tests/Unit/FoundationSnapshotServiceTest.php`.
|
||||||
|
- [x] T020 Add feature tests for foundations backup/restore preview and execute in `tests/Feature/FoundationBackupTest.php`, `tests/Feature/Filament/RestorePreviewTest.php`, `tests/Feature/Filament/RestoreExecutionTest.php`, `tests/Feature/RestoreScopeTagMappingTest.php`.
|
||||||
|
- [x] T021 Add feature tests for assignment mapping and skip reasons in `tests/Feature/RestoreAssignmentApplicationTest.php`.
|
||||||
|
- [x] T022 Add feature test for CA preview-only execution behavior in `tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`.
|
||||||
|
- [x] T023 Run tests: `./vendor/bin/sail artisan test tests/Unit/FoundationSnapshotServiceTest.php tests/Unit/FoundationMappingServiceTest.php tests/Unit/TenantPermissionServiceTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreAssignmentApplicationTest.php tests/Feature/RestoreScopeTagMappingTest.php tests/Feature/RestoreRunRerunTest.php`
|
||||||
|
- [x] T024 Run Pint: `./vendor/bin/pint --dirty`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Admin UX and Safety
|
||||||
|
|
||||||
|
**Purpose**: Improve admin clarity and safe reruns for restore flows.
|
||||||
|
|
||||||
|
- [x] T025 Update tenant permission display to ignore configured stub grants in `app/Services/Intune/TenantPermissionService.php` and `app/Filament/Resources/TenantResource.php`, plus tests in `tests/Unit/TenantPermissionServiceTest.php`.
|
||||||
|
- [x] T026 Improve restore item selection UX (searchable list, descriptions, hint) in `app/Filament/Resources/RestoreRunResource.php` with coverage in `tests/Feature/Filament/RestoreItemSelectionTest.php`.
|
||||||
|
- [x] T027 Add restore run rerun action and coverage in `app/Filament/Resources/RestoreRunResource.php` and `tests/Feature/RestoreRunRerunTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Tests pass and formatting is clean.
|
||||||
111
tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php
Normal file
111
tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('conditional access restores are preview-only and skipped on execution', function () {
|
||||||
|
$client = new class implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public int $applyCalls = 0;
|
||||||
|
|
||||||
|
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->applyCalls++;
|
||||||
|
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-ca',
|
||||||
|
'name' => 'Tenant CA',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'ca-policy-1',
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'display_name' => 'CA Policy',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'CA 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,
|
||||||
|
'payload' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.conditionalAccessPolicy',
|
||||||
|
'id' => $policy->external_id,
|
||||||
|
'displayName' => $policy->display_name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$preview = $service->preview($tenant, $backupSet, [$backupItem->id]);
|
||||||
|
|
||||||
|
$previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'conditionalAccessPolicy');
|
||||||
|
|
||||||
|
expect($previewItem)->not->toBeNull()
|
||||||
|
->and($previewItem['restore_mode'] ?? null)->toBe('preview-only');
|
||||||
|
|
||||||
|
$run = $service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$backupItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: 'tester@example.com',
|
||||||
|
actorName: 'Tester',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($run->results)->toHaveCount(1);
|
||||||
|
expect($run->results[0]['status'])->toBe('skipped');
|
||||||
|
expect($run->results[0]['reason'])->toBe('preview_only');
|
||||||
|
|
||||||
|
expect($client->applyCalls)->toBe(0);
|
||||||
|
});
|
||||||
@ -8,8 +8,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\FoundationMappingService;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -101,3 +103,82 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('restore execution records foundation mappings', function () {
|
||||||
|
config()->set('tenantpilot.foundation_types', [
|
||||||
|
[
|
||||||
|
'type' => 'assignmentFilter',
|
||||||
|
'label' => 'Assignment Filter',
|
||||||
|
'category' => 'Foundations',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/assignmentFilters',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'low',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||||
|
$backupItem = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-1',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$entries = [
|
||||||
|
[
|
||||||
|
'type' => 'assignmentFilter',
|
||||||
|
'sourceId' => 'filter-1',
|
||||||
|
'sourceName' => 'Filter One',
|
||||||
|
'decision' => 'created',
|
||||||
|
'targetId' => 'filter-2',
|
||||||
|
'targetName' => 'Filter One',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) {
|
||||||
|
$mock->shouldReceive('map')
|
||||||
|
->twice()
|
||||||
|
->andReturn([
|
||||||
|
'entries' => $entries,
|
||||||
|
'mapping' => ['filter-1' => 'filter-2'],
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$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,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->results)->toHaveCount(1);
|
||||||
|
expect($run->results[0]['decision'])->toBe('created');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'action' => 'restore.foundation.created',
|
||||||
|
'resource_id' => (string) $run->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
75
tests/Feature/Filament/RestoreItemSelectionTest.php
Normal file
75
tests/Feature/Filament/RestoreItemSelectionTest.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('restore selection shows readable labels and descriptions', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Policy Display',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||||
|
'item_count' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'tag-1',
|
||||||
|
'policy_type' => 'roleScopeTag',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'tag-1',
|
||||||
|
'displayName' => 'Scope Tag Alpha',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Scope Tag Alpha',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->assertSee('Policy Display')
|
||||||
|
->assertSee('Scope Tag Alpha')
|
||||||
|
->assertSee('Settings Catalog Policy')
|
||||||
|
->assertSee('Scope Tag')
|
||||||
|
->assertSee('restore: enabled')
|
||||||
|
->assertSee('id: policy-1')
|
||||||
|
->assertSee('id: tag-1')
|
||||||
|
->assertSee('Include foundations');
|
||||||
|
});
|
||||||
@ -76,9 +76,30 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'payload' => ['foo' => 'bar'],
|
'payload' => ['foo' => 'bar'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-1',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
$service = app(RestoreService::class);
|
$service = app(RestoreService::class);
|
||||||
$preview = $service->preview($tenant, $backupSet);
|
$preview = $service->preview($tenant, $backupSet);
|
||||||
|
|
||||||
expect($preview)->toHaveCount(1);
|
expect($preview)->toHaveCount(2);
|
||||||
expect($preview[0]['action'])->toBe('update');
|
|
||||||
|
$foundation = collect($preview)->first(fn (array $item) => isset($item['decision']));
|
||||||
|
expect($foundation['decision'])->toBe('created');
|
||||||
|
|
||||||
|
$policyPreview = collect($preview)->first(fn (array $item) => isset($item['action']));
|
||||||
|
expect($policyPreview['action'])->toBe('update');
|
||||||
});
|
});
|
||||||
|
|||||||
95
tests/Feature/FoundationBackupTest.php
Normal file
95
tests/Feature/FoundationBackupTest.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\BackupService;
|
||||||
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
|
use App\Services\Intune\PolicySnapshotService;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
|
||||||
|
$this->policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'display_name' => 'Test Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
config()->set('tenantpilot.foundation_types', [
|
||||||
|
[
|
||||||
|
'type' => 'assignmentFilter',
|
||||||
|
'label' => 'Assignment Filter',
|
||||||
|
'category' => 'Foundations',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/assignmentFilters',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'enabled',
|
||||||
|
'risk' => 'low',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetch')
|
||||||
|
->andReturn([
|
||||||
|
'payload' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceConfiguration',
|
||||||
|
'id' => 'policy-1',
|
||||||
|
'displayName' => 'Test Policy',
|
||||||
|
],
|
||||||
|
'metadata' => [],
|
||||||
|
'warnings' => [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetchAll')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'source_id' => 'filter-1',
|
||||||
|
'display_name' => 'Filter One',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
'kind' => 'assignmentFilter',
|
||||||
|
'graph' => [
|
||||||
|
'resource' => 'deviceManagement/assignmentFilters',
|
||||||
|
'apiVersion' => 'beta',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates foundation backup items when requested', function () {
|
||||||
|
$service = app(BackupService::class);
|
||||||
|
|
||||||
|
$backupSet = $service->createBackupSet(
|
||||||
|
tenant: $this->tenant,
|
||||||
|
policyIds: [$this->policy->id],
|
||||||
|
name: 'Foundation Backup',
|
||||||
|
includeFoundations: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($backupSet->items)->toHaveCount(2);
|
||||||
|
|
||||||
|
$foundationItem = BackupItem::query()
|
||||||
|
->where('backup_set_id', $backupSet->id)
|
||||||
|
->where('policy_type', 'assignmentFilter')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($foundationItem)->not->toBeNull();
|
||||||
|
expect($foundationItem->policy_id)->toBeNull();
|
||||||
|
expect($foundationItem->policy_identifier)->toBe('filter-1');
|
||||||
|
expect($foundationItem->metadata['displayName'])->toBe('Filter One');
|
||||||
|
});
|
||||||
@ -7,8 +7,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\FoundationMappingService;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -247,3 +249,109 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
expect($summary['failed'])->toBe(2);
|
expect($summary['failed'])->toBe(2);
|
||||||
expect($run->results[0]['status'])->toBe('partial');
|
expect($run->results[0]['status'])->toBe('partial');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('restore maps assignment filter identifiers', function () {
|
||||||
|
$applyResponse = new GraphResponse(true, []);
|
||||||
|
$requestResponses = [
|
||||||
|
new GraphResponse(true, []), // assign action
|
||||||
|
];
|
||||||
|
|
||||||
|
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'scp-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog Alpha',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foundationItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-old',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => ['id' => 'filter-old', 'displayName' => 'Filter Old'],
|
||||||
|
'metadata' => ['displayName' => 'Filter Old'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'id' => 'assignment-1',
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'source-group-1',
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => 'filter-old',
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$entries = [
|
||||||
|
[
|
||||||
|
'type' => 'assignmentFilter',
|
||||||
|
'sourceId' => 'filter-old',
|
||||||
|
'sourceName' => 'Filter Old',
|
||||||
|
'decision' => 'created',
|
||||||
|
'targetId' => 'filter-new',
|
||||||
|
'targetName' => 'Filter Old',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) {
|
||||||
|
$mock->shouldReceive('map')
|
||||||
|
->twice()
|
||||||
|
->andReturn([
|
||||||
|
'entries' => $entries,
|
||||||
|
'mapping' => ['filter-old' => 'filter-new'],
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$foundationItem->id, $backupItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
groupMapping: [
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$payloadAssignments = $client->requestCalls[0]['payload']['assignments'] ?? [];
|
||||||
|
|
||||||
|
expect($payloadAssignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-new');
|
||||||
|
});
|
||||||
|
|||||||
68
tests/Feature/RestoreRunRerunTest.php
Normal file
68
tests/Feature/RestoreRunRerunTest.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('rerun action creates a new restore run with the same selections', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'policy-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'policy-1',
|
||||||
|
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$run = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'status' => 'failed',
|
||||||
|
'is_dry_run' => true,
|
||||||
|
'requested_items' => [$backupItem->id],
|
||||||
|
'group_mapping' => [
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListRestoreRuns::class)
|
||||||
|
->callTableAction('rerun', $run);
|
||||||
|
|
||||||
|
$newRun = RestoreRun::query()
|
||||||
|
->where('backup_set_id', $backupSet->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($newRun)->not->toBeNull();
|
||||||
|
expect($newRun->id)->not->toBe($run->id);
|
||||||
|
expect($newRun->requested_items)->toBe([$backupItem->id]);
|
||||||
|
expect($newRun->group_mapping)->toBe([
|
||||||
|
'source-group-1' => 'target-group-1',
|
||||||
|
]);
|
||||||
|
expect($newRun->is_dry_run)->toBeTrue();
|
||||||
|
expect($newRun->requested_by)->toBe('tester@example.com');
|
||||||
|
});
|
||||||
143
tests/Feature/RestoreScopeTagMappingTest.php
Normal file
143
tests/Feature/RestoreScopeTagMappingTest.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\FoundationMappingService;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class RestoreScopeTagGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{policyType:string,policyId:string,payload:array}>
|
||||||
|
*/
|
||||||
|
public array $applyPolicyCalls = [];
|
||||||
|
|
||||||
|
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[] = [
|
||||||
|
'policyType' => $policyType,
|
||||||
|
'policyId' => $policyId,
|
||||||
|
'payload' => $payload,
|
||||||
|
];
|
||||||
|
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('restore applies scope tag mappings to policy payloads', function () {
|
||||||
|
$client = new RestoreScopeTagGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopeTagItem = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'tag-old',
|
||||||
|
'policy_type' => 'roleScopeTag',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'tag-old',
|
||||||
|
'displayName' => 'Scope Tag Alpha',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Scope Tag Alpha',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$policyItem = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'policy-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'policy-1',
|
||||||
|
'displayName' => 'Policy Alpha',
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||||
|
'roleScopeTagIds' => ['tag-old'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$entries = [
|
||||||
|
[
|
||||||
|
'type' => 'roleScopeTag',
|
||||||
|
'sourceId' => 'tag-old',
|
||||||
|
'sourceName' => 'Scope Tag Alpha',
|
||||||
|
'decision' => 'created',
|
||||||
|
'targetId' => 'tag-new',
|
||||||
|
'targetName' => 'Scope Tag Alpha',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) {
|
||||||
|
$mock->shouldReceive('map')
|
||||||
|
->twice()
|
||||||
|
->andReturn([
|
||||||
|
'entries' => $entries,
|
||||||
|
'mapping' => ['tag-old' => 'tag-new'],
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(RestoreService::class);
|
||||||
|
$service->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: [$scopeTagItem->id, $policyItem->id],
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($client->applyPolicyCalls)->toHaveCount(1);
|
||||||
|
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['tag-new']);
|
||||||
|
});
|
||||||
281
tests/Unit/FoundationMappingServiceTest.php
Normal file
281
tests/Unit/FoundationMappingServiceTest.php
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\FoundationMappingService;
|
||||||
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
class FoundationMappingGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public array $requests = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, GraphResponse> $responses
|
||||||
|
*/
|
||||||
|
public function __construct(private array $responses = []) {}
|
||||||
|
|
||||||
|
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: true, data: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'options' => $options,
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_shift($this->responses) ?? new GraphResponse(success: true, data: []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps existing foundations by display name', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||||
|
$item = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-1',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetchAll')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'source_id' => 'filter-2',
|
||||||
|
'display_name' => 'Filter One',
|
||||||
|
'payload' => [],
|
||||||
|
'metadata' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new FoundationMappingGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationMappingService::class);
|
||||||
|
$result = $service->map($tenant, collect([$item]), false);
|
||||||
|
|
||||||
|
expect($result['failed'])->toBe(0);
|
||||||
|
expect($result['skipped'])->toBe(0);
|
||||||
|
expect($result['mapping'])->toBe(['filter-1' => 'filter-2']);
|
||||||
|
expect($result['entries'])->toHaveCount(1);
|
||||||
|
expect($result['entries'][0]['decision'])->toBe('mapped_existing');
|
||||||
|
expect($result['entries'][0]['targetId'])->toBe('filter-2');
|
||||||
|
expect($result['entries'][0]['sourceName'])->toBe('Filter One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates missing foundations when executing', function () {
|
||||||
|
config()->set('graph_contracts.types.assignmentFilter', [
|
||||||
|
'resource' => 'deviceManagement/assignmentFilters',
|
||||||
|
'create_method' => 'POST',
|
||||||
|
'update_strip_keys' => ['isBuiltIn'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => 'tenant-1',
|
||||||
|
'app_client_id' => 'client-1',
|
||||||
|
'app_client_secret' => 'secret-1',
|
||||||
|
]);
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||||
|
$item = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-1',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceAndAppManagementAssignmentFilter',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
'isBuiltIn' => false,
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetchAll')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'source_id' => 'filter-2',
|
||||||
|
'display_name' => 'Filter One',
|
||||||
|
'payload' => [],
|
||||||
|
'metadata' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'source_id' => 'filter-3',
|
||||||
|
'display_name' => 'Filter One',
|
||||||
|
'payload' => [],
|
||||||
|
'metadata' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new FoundationMappingGraphClient([
|
||||||
|
new GraphResponse(true, [
|
||||||
|
'id' => 'filter-99',
|
||||||
|
'displayName' => 'Filter One (Copy)',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationMappingService::class);
|
||||||
|
$result = $service->map($tenant, collect([$item]), true);
|
||||||
|
|
||||||
|
expect($result['mapping'])->toBe(['filter-1' => 'filter-99']);
|
||||||
|
expect($result['entries'][0]['decision'])->toBe('created_copy');
|
||||||
|
expect($result['entries'][0]['targetName'])->toBe('Filter One (Copy)');
|
||||||
|
expect($client->requests)->toHaveCount(1);
|
||||||
|
expect($client->requests[0]['method'])->toBe('POST');
|
||||||
|
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters');
|
||||||
|
|
||||||
|
$payload = $client->requests[0]['options']['json'];
|
||||||
|
expect($payload['displayName'])->toBe('Filter One (Copy)');
|
||||||
|
expect($payload)->not->toHaveKey('id');
|
||||||
|
expect($payload)->not->toHaveKey('@odata.type');
|
||||||
|
expect($payload)->not->toHaveKey('isBuiltIn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips built-in scope tags', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||||
|
$item = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => '0',
|
||||||
|
'policy_type' => 'roleScopeTag',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => '0',
|
||||||
|
'displayName' => 'Default',
|
||||||
|
'isBuiltIn' => true,
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Default',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetchAll')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'items' => [],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new FoundationMappingGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationMappingService::class);
|
||||||
|
$result = $service->map($tenant, collect([$item]), false);
|
||||||
|
|
||||||
|
expect($result['skipped'])->toBe(1);
|
||||||
|
expect($result['entries'][0]['decision'])->toBe('skipped');
|
||||||
|
expect($result['entries'][0]['reason'])->toBe('Built-in scope tag cannot be created.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks failures when foundation listing fails', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||||
|
$item = BackupItem::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->for($backupSet)
|
||||||
|
->state([
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'filter-1',
|
||||||
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'platform' => 'all',
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'filter-1',
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Filter One',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->mock(FoundationSnapshotService::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('fetchAll')
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'items' => [],
|
||||||
|
'failures' => [
|
||||||
|
[
|
||||||
|
'foundation_type' => 'assignmentFilter',
|
||||||
|
'reason' => 'Graph failure',
|
||||||
|
'status' => 500,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new FoundationMappingGraphClient;
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationMappingService::class);
|
||||||
|
$result = $service->map($tenant, collect([$item]), false);
|
||||||
|
|
||||||
|
expect($result['failed'])->toBe(1);
|
||||||
|
expect($result['entries'][0]['decision'])->toBe('failed');
|
||||||
|
expect($result['entries'][0]['reason'])->toBe('Graph failure');
|
||||||
|
});
|
||||||
121
tests/Unit/FoundationSnapshotServiceTest.php
Normal file
121
tests/Unit/FoundationSnapshotServiceTest.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class FoundationSnapshotGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public array $requests = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, GraphResponse> $responses
|
||||||
|
*/
|
||||||
|
public function __construct(private array $responses) {}
|
||||||
|
|
||||||
|
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: true, data: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'options' => $options,
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_shift($this->responses) ?? new GraphResponse(success: true, data: []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns a failure when the foundation contract is missing', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$client = new FoundationSnapshotGraphClient([]);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationSnapshotService::class);
|
||||||
|
$result = $service->fetchAll($tenant, 'unknownFoundation');
|
||||||
|
|
||||||
|
expect($result['items'])->toBeEmpty();
|
||||||
|
expect($result['failures'])->toHaveCount(1);
|
||||||
|
expect($result['failures'][0]['foundation_type'])->toBe('unknownFoundation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates foundation snapshots across pages with metadata', function () {
|
||||||
|
config()->set('graph_contracts.types.assignmentFilter', [
|
||||||
|
'resource' => 'deviceManagement/assignmentFilters',
|
||||||
|
'allowed_select' => ['id', 'displayName'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => 'tenant-123',
|
||||||
|
'app_client_id' => 'client-123',
|
||||||
|
'app_client_secret' => 'secret-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$responses = [
|
||||||
|
new GraphResponse(
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
'value' => [
|
||||||
|
['id' => 'filter-1', 'displayName' => 'Filter One'],
|
||||||
|
],
|
||||||
|
'@odata.nextLink' => 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$skiptoken=abc',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
new GraphResponse(
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
'value' => [
|
||||||
|
['id' => 'filter-2', 'displayName' => 'Filter Two'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$client = new FoundationSnapshotGraphClient($responses);
|
||||||
|
app()->instance(GraphClientInterface::class, $client);
|
||||||
|
|
||||||
|
$service = app(FoundationSnapshotService::class);
|
||||||
|
$result = $service->fetchAll($tenant, 'assignmentFilter');
|
||||||
|
|
||||||
|
expect($result['items'])->toHaveCount(2);
|
||||||
|
expect($result['items'][0]['source_id'])->toBe('filter-1');
|
||||||
|
expect($result['items'][0]['metadata']['displayName'])->toBe('Filter One');
|
||||||
|
expect($result['items'][0]['metadata']['kind'])->toBe('assignmentFilter');
|
||||||
|
expect($result['items'][1]['source_id'])->toBe('filter-2');
|
||||||
|
|
||||||
|
expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters');
|
||||||
|
expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']);
|
||||||
|
expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc');
|
||||||
|
expect($client->requests[1]['options']['query'])->toBe([]);
|
||||||
|
});
|
||||||
@ -121,3 +121,35 @@ function requiredPermissions(): array
|
|||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores configured stub permissions when requested', function () {
|
||||||
|
$originalPermissions = config('intune_permissions.permissions');
|
||||||
|
$originalStub = config('intune_permissions.granted_stub');
|
||||||
|
|
||||||
|
config()->set('intune_permissions.permissions', [
|
||||||
|
[
|
||||||
|
'key' => 'DeviceManagementRBAC.ReadWrite.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => null,
|
||||||
|
'features' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
config()->set('intune_permissions.granted_stub', ['DeviceManagementRBAC.ReadWrite.All']);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
TenantPermission::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'permission_key' => 'DeviceManagementRBAC.ReadWrite.All',
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => ['source' => 'configured'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(TenantPermissionService::class)->compare($tenant, persist: false, useConfiguredStub: false);
|
||||||
|
|
||||||
|
expect($result['overall_status'])->toBe('missing');
|
||||||
|
expect($result['permissions'][0]['status'])->toBe('missing');
|
||||||
|
|
||||||
|
config()->set('intune_permissions.permissions', $originalPermissions);
|
||||||
|
config()->set('intune_permissions.granted_stub', $originalStub);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user