feat(006): foundations + assignment mapping and preview-only restore guard #7

Merged
ahmido merged 8 commits from feat/006-sot-foundations-assignments into dev 2025-12-26 23:44:32 +00:00
28 changed files with 2609 additions and 274 deletions
Showing only changes of commit 2a04ab0a75 - Show all commits

View File

@ -362,7 +362,12 @@ private static function typeMeta(?string $type): array
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) ?? [];
}

View File

@ -26,9 +26,10 @@ public function table(Table $table): Table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Policy')
->label('Item')
->sortable()
->searchable(),
->searchable()
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
@ -112,6 +113,10 @@ public function table(Table $table): Table
->label('Include scope tags')
->default(true)
->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) {
if (empty($data['policy_ids'])) {
@ -134,10 +139,15 @@ public function table(Table $table): Table
actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? 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()
->title('Policies added to backup')
->title($notificationTitle)
->success()
->send();
}),
@ -191,7 +201,12 @@ private static function typeMeta(?string $type): array
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) ?? [];
}
}

View File

@ -75,38 +75,14 @@ public static function form(Schema $schema): Schema
->required(),
Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore (optional)')
->options(function (Get $get) {
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return [];
}
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)
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
->columns(1)
->searchable()
->bulkToggleable()
->reactive()
->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')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
@ -182,6 +158,75 @@ public static function table(Table $table): Table
->actions([
Actions\ViewAction::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')
->label('Restore')
->color('success')
@ -465,10 +510,82 @@ private static function typeMeta(?string $type): array
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) ?? [];
}
/**
* @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
{
/** @var Tenant $tenant */

View File

@ -298,7 +298,7 @@ public static function infolist(Schema $schema): Schema
->copyable(),
Infolists\Components\RepeatableEntry::make('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([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),

View File

@ -30,6 +30,7 @@ public function __construct(
public string $policyId,
public array $assignments,
public array $groupMapping,
public array $foundationMapping = [],
public ?string $actorEmail = null,
public ?string $actorName = null,
) {}
@ -61,6 +62,7 @@ public function handle(AssignmentRestoreService $assignmentRestoreService): arra
policyId: $this->policyId,
assignments: $this->assignments,
groupMapping: $this->groupMapping,
foundationMapping: $this->foundationMapping,
restoreRun: $restoreRun,
actorEmail: $this->actorEmail,
actorName: $this->actorName,

View File

@ -84,6 +84,34 @@ public function assignmentsFetchFailed(): bool
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
public function scopeWithAssignments($query)
{

View File

@ -24,6 +24,7 @@ public function __construct(
/**
* @param array<int, array<string, mixed>> $assignments
* @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}}
*/
public function restore(
@ -32,6 +33,7 @@ public function restore(
string $policyId,
array $assignments,
array $groupMapping,
array $foundationMapping = [],
?RestoreRun $restoreRun = null,
?string $actorEmail = null,
?string $actorName = null,
@ -80,11 +82,70 @@ public function restore(
$preparedAssignments = [];
$preparedMeta = [];
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
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;
$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 [
'status' => 'skipped',
'assignment' => $this->sanitizeAssignment($assignment),
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
'reason' => $reason,
];
}

View File

@ -19,6 +19,7 @@ public function __construct(
private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentBackupService $assignmentBackupService,
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
private readonly FoundationSnapshotService $foundationSnapshots,
) {}
/**
@ -34,6 +35,7 @@ public function createBackupSet(
?string $name = null,
bool $includeAssignments = false,
bool $includeScopeTags = false,
bool $includeFoundations = false,
): BackupSet {
$this->assertActiveTenant($tenant);
@ -42,7 +44,7 @@ public function createBackupSet(
->whereIn('id', $policyIds)
->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([
'tenant_id' => $tenant->id,
'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);
$backupSet->update([
@ -145,6 +153,7 @@ public function addPoliciesToSet(
?string $actorName = null,
bool $includeAssignments = false,
bool $includeScopeTags = false,
bool $includeFoundations = false,
): BackupSet {
$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);
$backupSet->update([
@ -307,6 +322,70 @@ private function snapshotPolicy(
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
{
if (! $tenant->isActive()) {

View 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
);
}
}

View 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, '/');
}
}

View File

@ -27,6 +27,7 @@ public function __construct(
private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts,
private readonly AssignmentRestoreService $assignmentRestoreService,
private readonly FoundationMappingService $foundationMappingService,
) {}
/**
@ -40,7 +41,11 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
$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()
->where('tenant_id', $tenant->id)
->where('external_id', $item->policy_identifier)
@ -61,6 +66,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
];
})->all();
return array_merge($foundationPreview, $policyPreview);
}
/**
@ -81,6 +88,7 @@ public function execute(
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
$restoreRun = RestoreRun::create([
@ -115,10 +123,27 @@ public function execute(
);
}
$results = [];
$hardFailures = 0;
$foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun);
$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 = [
'tenant' => $tenantIdentifier,
'policy_type' => $item->policy_type,
@ -157,9 +182,12 @@ public function execute(
try {
$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
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
$graphOptions = [
'tenant' => $tenantIdentifier,
@ -300,6 +328,7 @@ public function execute(
policyId: $assignmentPolicyId,
assignments: $item->assignments,
groupMapping: $groupMapping,
foundationMapping: $foundationMappingByType,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
@ -366,8 +395,16 @@ public function execute(
}
$resultStatuses = collect($results)->pluck('status')->all();
$nonApplied = collect($resultStatuses)->filter(fn (string $status) => $status !== 'applied' && $status !== 'dry_run')->count();
$allHardFailed = count($results) > 0 && $hardFailures === count($results);
$nonApplied = collect($resultStatuses)->filter(fn ($status) => is_string($status) && $status !== 'applied' && $status !== 'dry_run')->count();
$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
? 'previewed'
@ -384,7 +421,8 @@ public function execute(
'metadata' => [
'failed' => $hardFailures,
'non_applied' => $nonApplied,
'total' => count($results),
'total' => $totalCount,
'foundations_skipped' => $foundationSkipped,
],
]);
@ -409,6 +447,160 @@ public function execute(
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];
}
/**
* @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
*/

View File

@ -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 bool $persist Persist comparison results to tenant_permissions
* @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}>}
*/
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();
$liveCheckFailed = false;
$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(
$grantedStatuses ?? array_replace_recursive($this->configuredGrantedStatuses(), $this->getGrantedPermissions($tenant))
$grantedStatuses ?? array_replace_recursive(
$useConfiguredStub && ! $liveCheck ? $this->configuredGrantedStatuses() : [],
$storedStatuses
)
);
$results = [];
$hasMissing = false;
@ -138,6 +153,23 @@ private function normalizeGrantedStatuses(array $granted): array
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}>
*/

View File

@ -197,5 +197,56 @@
'id_field' => 'id',
'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',
],
],
],
];

View File

@ -62,6 +62,12 @@
'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.',
'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',
'type' => 'application',

View File

@ -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' => [
'conditional_access' => true,
],

View File

@ -1,12 +1,61 @@
@php
$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
@if (empty($preview))
<p class="text-sm text-gray-600">No preview available.</p>
@else
<div class="space-y-4">
@if ($foundationItems->isNotEmpty())
<div class="space-y-2">
@foreach ($preview as $item)
<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-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
{{ $item['reason'] }}
</div>
@endif
</div>
@endforeach
</div>
@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)
<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>
@ -27,3 +76,5 @@
@endforeach
</div>
@endif
</div>
@endif

View File

@ -1,25 +1,75 @@
@php
$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
@if (empty($results))
<p class="text-sm text-gray-600">No results recorded.</p>
@else
@php
$needsAttention = collect($results)->contains(function ($item) {
$needsAttention = $policyItems->contains(function ($item) {
$status = $item['status'] ?? null;
return in_array($status, ['partial', 'manual_required'], true);
});
@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)
<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.
</div>
@endif
@foreach ($results as $item)
@if ($policyItems->isNotEmpty())
<div class="space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
@foreach ($policyItems 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">
@ -241,3 +291,5 @@
@endforeach
</div>
@endif
</div>
@endif

View File

@ -0,0 +1,88 @@
# 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.
- [ ] T001 [P] Add foundation type registry in `config/tenantpilot.php` (assignmentFilter, roleScopeTag, notificationMessageTemplate) with label/category/backup/restore/risk metadata.
- [ ] T002 [P] Extend `config/graph_contracts.php` with foundation contracts (resource, create/update methods, id_field, allowed_select, type_family).
- [ ] T003 [P] Extend `config/intune_permissions.php` to include foundation permissions (DeviceManagementRBAC.ReadWrite.All and any missing read/write scopes for filters/templates).
- [ ] 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.
- [ ] T005 Create `app/Services/Intune/FoundationSnapshotService.php` to list and fetch foundation objects with Graph paging, normalized metadata, and fail-soft behavior.
- [ ] 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).
- [ ] T007 Add a UI action/toggle to include foundations when adding to a backup set in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
- [ ] 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.
- [ ] T009 Create `app/Services/Intune/FoundationMappingService.php` to match by displayName, handle collisions, and emit report entries matching `contracts/restore-mapping-report.schema.json`.
- [ ] 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`.
- [ ] T011 Add audit events for foundation mapping decisions and failures in `app/Services/Intune/AuditLogger.php`.
- [ ] 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.
- [ ] T013 Extend `app/Services/AssignmentRestoreService.php` to map assignment filter IDs and scope tag IDs via the foundation mapping; skip and record reasons when mappings are missing.
- [ ] T014 Update `app/Services/Intune/RestoreService.php` to pass foundation mappings into assignment restore and include decision summaries in results.
- [ ] T015 Add mapping context to assignment audit logs in `app/Services/Intune/AuditLogger.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.
- [ ] T016 Update `app/Services/Intune/RestoreService.php` to prevent CA execution (status skipped, reason preview_only) while keeping preview output.
- [ ] 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.
- [ ] T018 [P] Add unit tests for FoundationMappingService in `tests/Unit/FoundationMappingServiceTest.php`.
- [ ] T019 [P] Add unit tests for FoundationSnapshotService in `tests/Unit/FoundationSnapshotServiceTest.php`.
- [ ] T020 Add feature tests for foundations backup/restore preview and execute in `tests/Feature/Filament/FoundationRestoreTest.php`.
- [ ] T021 Add feature tests for assignment mapping and skip reasons in `tests/Feature/Filament/AssignmentRestoreMappingTest.php`.
- [ ] T022 Add feature test for CA preview-only execution behavior in `tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`.
- [ ] T023 Run tests: `./vendor/bin/sail artisan test tests/Feature/Filament/FoundationRestoreTest.php tests/Feature/Filament/AssignmentRestoreMappingTest.php tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`
- [ ] T024 Run Pint: `./vendor/bin/pint --dirty`
**Checkpoint**: Tests pass and formatting is clean.

View File

@ -8,8 +8,10 @@
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);
@ -101,3 +103,82 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
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,
]);
});

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

View File

@ -76,9 +76,30 @@ public function request(string $method, string $path, array $options = []): Grap
'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);
$preview = $service->preview($tenant, $backupSet);
expect($preview)->toHaveCount(1);
expect($preview[0]['action'])->toBe('update');
expect($preview)->toHaveCount(2);
$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');
});

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

View File

@ -7,8 +7,10 @@
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);
@ -247,3 +249,109 @@ public function request(string $method, string $path, array $options = []): Grap
expect($summary['failed'])->toBe(2);
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');
});

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

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

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

View 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([]);
});

View File

@ -121,3 +121,35 @@ function requiredPermissions(): array
'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);
});