Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
629bbef6e9 merge: origin/dev into feat/005-bulk-operations 2025-12-25 04:19:58 +01:00
f4cf1dce6e feat/004-assignments-scope-tags (#4)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #4
2025-12-23 21:49:58 +00:00
67 changed files with 8666 additions and 165 deletions

View File

@ -21,6 +21,8 @@ ## TenantPilot setup
- `GRAPH_CLIENT_SECRET` - `GRAPH_CLIENT_SECRET`
- `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`) - `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`)
- Without these, the `NullGraphClient` runs in dry mode (no Graph calls). - Without these, the `NullGraphClient` runs in dry mode (no Graph calls).
- **Required API Permissions**: See [docs/PERMISSIONS.md](docs/PERMISSIONS.md) for complete list
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
- Deployment (Dokploy, staging → production): - Deployment (Dokploy, staging → production):
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline). - Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
- Run migrations on staging first, validate backup/restore flows, then promote to production. - Run migrations on staging first, validate backup/restore flows, then promote to production.

View File

@ -367,6 +367,8 @@ public static function createBackupSet(array $data): BackupSet
name: $data['name'] ?? null, name: $data['name'] ?? null,
actorEmail: auth()->user()?->email, actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name, actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
); );
} }
} }

View File

@ -23,6 +23,7 @@ class BackupItemsRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->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('Policy')
@ -46,13 +47,41 @@ public function table(Table $table): Table
->label('Policy ID') ->label('Policy ID')
->copyable(), ->copyable(),
Tables\Columns\TextColumn::make('platform')->badge(), Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('assignments')
->label('Assignments')
->badge()
->color('info')
->getStateUsing(function (BackupItem $record): int {
$assignments = $record->policyVersion?->assignments ?? $record->assignments ?? [];
return is_array($assignments) ? count($assignments) : 0;
}),
Tables\Columns\TextColumn::make('scope_tags')
->label('Scope Tags')
->default('—')
->getStateUsing(function (BackupItem $record): array {
$tags = $record->policyVersion?->scope_tags['names'] ?? [];
return is_array($tags) ? $tags : [];
})
->formatStateUsing(function ($state): string {
if (is_array($state)) {
return $state === [] ? '—' : implode(', ', $state);
}
if (is_string($state) && $state !== '') {
return $state;
}
return '—';
}),
Tables\Columns\TextColumn::make('captured_at')->dateTime(), Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('created_at')->since(), Tables\Columns\TextColumn::make('created_at')->since(),
]) ])
->filters([]) ->filters([])
->headerActions([ ->headerActions([
Actions\Action::make('addPolicies') Actions\Action::make('addPolicies')
->label('Policies hinzufügen') ->label('Add Policies')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
->form([ ->form([
Forms\Components\Select::make('policy_ids') Forms\Components\Select::make('policy_ids')
@ -70,10 +99,19 @@ public function table(Table $table): Table
return Policy::query() return Policy::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround)
->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing))
->orderBy('display_name') ->orderBy('display_name')
->pluck('display_name', 'id'); ->pluck('display_name', 'id');
}), }),
Forms\Components\Checkbox::make('include_assignments')
->label('Include assignments')
->default(true)
->helperText('Captures assignment include/exclude targeting and filters.'),
Forms\Components\Checkbox::make('include_scope_tags')
->label('Include scope tags')
->default(true)
->helperText('Captures policy scope tag IDs.'),
]) ])
->action(function (array $data, BackupService $service) { ->action(function (array $data, BackupService $service) {
if (empty($data['policy_ids'])) { if (empty($data['policy_ids'])) {
@ -94,6 +132,8 @@ public function table(Table $table): Table
policyIds: $data['policy_ids'], policyIds: $data['policy_ids'],
actorEmail: auth()->user()?->email, actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name, actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
); );
Notification::make() Notification::make()

View File

@ -214,6 +214,11 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(function (Builder $query) {
// Quick-Workaround: Hide policies not synced in last 7 days
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
$query->where('last_synced_at', '>', now()->subDays(7));
})
->columns([ ->columns([
Tables\Columns\TextColumn::make('display_name') Tables\Columns\TextColumn::make('display_name')
->label('Policy') ->label('Policy')

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Services\Intune\VersionService; use App\Services\Intune\VersionService;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
@ -23,7 +24,17 @@ protected function getActions(): array
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Capture snapshot now') ->modalHeading('Capture snapshot now')
->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.') ->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.')
->action(function () { ->form([
Forms\Components\Checkbox::make('include_assignments')
->label('Include assignments')
->default(true)
->helperText('Captures assignment include/exclude targeting and filters.'),
Forms\Components\Checkbox::make('include_scope_tags')
->label('Include scope tags')
->default(true)
->helperText('Captures policy scope tag IDs.'),
])
->action(function (array $data) {
$policy = $this->record; $policy = $this->record;
try { try {
@ -38,7 +49,13 @@ protected function getActions(): array
return; return;
} }
app(VersionService::class)->captureFromGraph($tenant, $policy, auth()->user()?->email ?? null); app(VersionService::class)->captureFromGraph(
tenant: $tenant,
policy: $policy,
createdBy: auth()->user()?->email ?? null,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
);
Notification::make() Notification::make()
->title('Snapshot captured successfully.') ->title('Snapshot captured successfully.')

View File

@ -5,10 +5,18 @@
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
use Illuminate\Contracts\View\View;
class ViewPolicyVersion extends ViewRecord class ViewPolicyVersion extends ViewRecord
{ {
protected static string $resource = PolicyVersionResource::class; protected static string $resource = PolicyVersionResource::class;
protected Width|string|null $maxContentWidth = Width::Full; protected Width|string|null $maxContentWidth = Width::Full;
public function getFooter(): ?View
{
return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [
'record' => $this->getRecord(),
]);
}
} }

View File

@ -11,6 +11,8 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -21,13 +23,16 @@
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
@ -63,6 +68,10 @@ public static function form(Schema $schema): Schema
}); });
}) })
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []);
$set('group_mapping', []);
})
->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)')
@ -95,7 +104,57 @@ public static function form(Schema $schema): Schema
}); });
}) })
->columns(2) ->columns(2)
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), ->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
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 {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
);
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
$groupId = $group['id'];
$label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}")
->label($label)
->options([
'SKIP' => 'Skip assignment',
])
->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value))
->required()
->helperText('Choose a target group or select Skip.');
}, $unresolved);
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
) !== [];
}),
Forms\Components\Toggle::make('is_dry_run') Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true), ->default(true),
@ -407,6 +466,161 @@ public static function createRestoreRun(array $data): RestoreRun
dryRun: (bool) ($data['is_dry_run'] ?? true), dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email, actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name, actorName: auth()->user()?->name,
groupMapping: $data['group_mapping'] ?? [],
); );
} }
/**
* @param array<int>|null $selectedItemIds
* @return array<int, array{id:string,label:string}>
*/
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
{
if (! $backupSetId) {
return [];
}
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
$items = $query->get(['assignments']);
$assignments = [];
$sourceNames = [];
foreach ($items as $item) {
if (! is_array($item->assignments) || $item->assignments === []) {
continue;
}
foreach ($item->assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (! in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true)) {
continue;
}
$groupId = $target['groupId'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$assignments[] = $groupId;
$displayName = $target['group_display_name'] ?? null;
if (is_string($displayName) && $displayName !== '') {
$sourceNames[$groupId] = $displayName;
}
}
}
$groupIds = array_values(array_unique($assignments));
if ($groupIds === []) {
return [];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$unresolved = [];
foreach ($groupIds as $groupId) {
$group = $resolved[$groupId] ?? null;
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
continue;
}
$label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId);
$unresolved[] = [
'id' => $groupId,
'label' => $label,
];
}
return $unresolved;
}
/**
* @return array<string, string>
*/
private static function targetGroupOptions(Tenant $tenant, string $search): array
{
if (mb_strlen($search) < 2) {
return [];
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups',
[
'query' => [
'$filter' => sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($search)
),
'$select' => 'id,displayName',
'$top' => 20,
],
] + $tenant->graphOptions()
);
} catch (\Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
return collect($response->data['value'] ?? [])
->filter(fn (array $group) => filled($group['id'] ?? null))
->mapWithKeys(fn (array $group) => [
$group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']),
])
->all();
}
private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string
{
if (! $groupId) {
return $groupId;
}
if ($groupId === 'SKIP') {
return 'Skip assignment';
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions);
$group = $resolved[$groupId] ?? null;
return static::formatGroupLabel($group['displayName'] ?? null, $groupId);
}
private static function formatGroupLabel(?string $displayName, string $id): string
{
$suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
return trim(($displayName ?: 'Security group').$suffix);
}
private static function escapeOdataValue(string $value): string
{
return str_replace("'", "''", $value);
}
} }

View File

@ -248,12 +248,37 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(), Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(), Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(), Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
Infolists\Components\TextEntry::make('status')->badge(), Infolists\Components\TextEntry::make('status')
Infolists\Components\TextEntry::make('app_status')->badge(), ->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'gray',
'suspended' => 'warning',
'error' => 'danger',
default => 'gray',
}),
Infolists\Components\TextEntry::make('app_status')
->badge()
->color(fn (string $state): string => match ($state) {
'ok', 'configured' => 'success',
'pending' => 'warning',
'error' => 'danger',
'requires_consent' => 'warning',
default => 'gray',
}),
Infolists\Components\TextEntry::make('app_notes')->label('Notes'), Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
Infolists\Components\TextEntry::make('created_at')->dateTime(), Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(), Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status')->label('RBAC status')->badge(), Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->color(fn (string $state): string => match ($state) {
'ok', 'configured' => 'success',
'manual_assignment_required' => 'warning',
'error', 'failed' => 'danger',
'not_configured' => 'gray',
default => 'gray',
}),
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'), Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(), Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'), Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
@ -281,7 +306,13 @@ public static function infolist(Schema $schema): Schema
->label('Features') ->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state), ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->badge(), ->badge()
->color(fn (string $state): string => match ($state) {
'granted' => 'success',
'missing' => 'warning',
'error' => 'danger',
default => 'gray',
}),
]) ])
->columnSpanFull(), ->columnSpanFull(),
]); ]);
@ -908,7 +939,7 @@ public static function verifyTenant(
actorEmail: $user?->email, actorEmail: $user?->email,
actorName: $user?->name, actorName: $user?->name,
status: match ($permissions['overall_status']) { status: match ($permissions['overall_status']) {
'ok' => 'success', 'granted' => 'success',
'error' => 'error', 'error' => 'error',
default => 'partial', default => 'partial',
}, },

View File

@ -0,0 +1,87 @@
<?php
namespace App\Jobs;
use App\Models\BackupItem;
use App\Services\AssignmentBackupService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class FetchAssignmentsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 1;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 0;
/**
* Create a new job instance.
*/
public function __construct(
public int $backupItemId,
public string $tenantExternalId,
public string $policyExternalId,
public array $policyPayload
) {}
/**
* Execute the job.
*/
public function handle(AssignmentBackupService $assignmentBackupService): void
{
try {
$backupItem = BackupItem::find($this->backupItemId);
if ($backupItem === null) {
Log::warning('FetchAssignmentsJob: BackupItem not found', [
'backup_item_id' => $this->backupItemId,
]);
return;
}
// Only process Settings Catalog policies
if ($backupItem->policy_type !== 'settingsCatalogPolicy') {
Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [
'backup_item_id' => $this->backupItemId,
'policy_type' => $backupItem->policy_type,
]);
return;
}
$assignmentBackupService->enrichWithAssignments(
backupItem: $backupItem,
tenantId: $this->tenantExternalId,
policyId: $this->policyExternalId,
policyPayload: $this->policyPayload,
includeAssignments: true
);
Log::info('FetchAssignmentsJob: Successfully enriched BackupItem', [
'backup_item_id' => $this->backupItemId,
'assignment_count' => $backupItem->getAssignmentCount(),
]);
} catch (\Throwable $e) {
Log::error('FetchAssignmentsJob: Failed to enrich BackupItem', [
'backup_item_id' => $this->backupItemId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Don't retry - fail soft
$this->fail($e);
}
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Jobs;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RestoreAssignmentsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $backoff = 0;
/**
* Create a new job instance.
*/
public function __construct(
public int $restoreRunId,
public int $tenantId,
public string $policyType,
public string $policyId,
public array $assignments,
public array $groupMapping,
public ?string $actorEmail = null,
public ?string $actorName = null,
) {}
/**
* Execute the job.
*/
public function handle(AssignmentRestoreService $assignmentRestoreService): array
{
$restoreRun = RestoreRun::find($this->restoreRunId);
$tenant = Tenant::find($this->tenantId);
if (! $restoreRun || ! $tenant) {
Log::warning('RestoreAssignmentsJob missing context', [
'restore_run_id' => $this->restoreRunId,
'tenant_id' => $this->tenantId,
]);
return [
'outcomes' => [],
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
];
}
try {
return $assignmentRestoreService->restore(
tenant: $tenant,
policyType: $this->policyType,
policyId: $this->policyId,
assignments: $this->assignments,
groupMapping: $this->groupMapping,
restoreRun: $restoreRun,
actorEmail: $this->actorEmail,
actorName: $this->actorName,
);
} catch (\Throwable $e) {
Log::error('RestoreAssignmentsJob failed', [
'restore_run_id' => $this->restoreRunId,
'policy_id' => $this->policyId,
'error' => $e->getMessage(),
]);
return [
'outcomes' => [[
'status' => 'failed',
'reason' => $e->getMessage(),
]],
'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0],
];
}
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Livewire;
use App\Models\PolicyVersion;
use Livewire\Component;
class PolicyVersionAssignmentsWidget extends Component
{
public PolicyVersion $version;
public function mount(PolicyVersion $version): void
{
$this->version = $version;
}
public function render()
{
return view('livewire.policy-version-assignments-widget', [
'version' => $this->version,
]);
}
}

View File

@ -19,6 +19,7 @@ class BackupItem extends Model
protected $casts = [ protected $casts = [
'payload' => 'array', 'payload' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'assignments' => 'array',
'captured_at' => 'datetime', 'captured_at' => 'datetime',
]; ];
@ -36,4 +37,57 @@ public function policy(): BelongsTo
{ {
return $this->belongsTo(Policy::class); return $this->belongsTo(Policy::class);
} }
public function policyVersion(): BelongsTo
{
return $this->belongsTo(PolicyVersion::class);
}
// Assignment helpers
public function getAssignmentCountAttribute(): int
{
return count($this->assignments ?? []);
}
public function hasAssignments(): bool
{
return ! empty($this->assignments);
}
public function getGroupIdsAttribute(): array
{
return collect($this->assignments ?? [])
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
}
public function getScopeTagIdsAttribute(): array
{
return $this->metadata['scope_tag_ids'] ?? ['0'];
}
public function getScopeTagNamesAttribute(): array
{
return $this->metadata['scope_tag_names'] ?? ['Default'];
}
public function hasOrphanedAssignments(): bool
{
return $this->metadata['has_orphaned_assignments'] ?? false;
}
public function assignmentsFetchFailed(): bool
{
return $this->metadata['assignments_fetch_failed'] ?? false;
}
// Scopes
public function scopeWithAssignments($query)
{
return $query->whereNotNull('assignments')
->whereRaw('json_array_length(assignments) > 0');
}
} }

View File

@ -17,6 +17,8 @@ class PolicyVersion extends Model
protected $casts = [ protected $casts = [
'snapshot' => 'array', 'snapshot' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'assignments' => 'array',
'scope_tags' => 'array',
'captured_at' => 'datetime', 'captured_at' => 'datetime',
]; ];

View File

@ -20,6 +20,7 @@ class RestoreRun extends Model
'preview' => 'array', 'preview' => 'array',
'results' => 'array', 'results' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'group_mapping' => 'array',
'started_at' => 'datetime', 'started_at' => 'datetime',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];
@ -46,4 +47,81 @@ public function isDeletable(): bool
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true);
} }
// Group mapping helpers
public function hasGroupMapping(): bool
{
return ! empty($this->group_mapping);
}
public function getMappedGroupId(string $sourceGroupId): ?string
{
$mapping = $this->group_mapping ?? [];
return $mapping[$sourceGroupId] ?? null;
}
public function isGroupSkipped(string $sourceGroupId): bool
{
$mapping = $this->group_mapping ?? [];
return ($mapping[$sourceGroupId] ?? null) === 'SKIP';
}
public function getUnmappedGroupIds(array $sourceGroupIds): array
{
return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? []));
}
public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void
{
$mapping = $this->group_mapping ?? [];
$mapping[$sourceGroupId] = $targetGroupId;
$this->group_mapping = $mapping;
}
// Assignment restore outcome helpers
public function getAssignmentRestoreOutcomes(): array
{
$results = $this->results ?? [];
if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) {
return $results['assignment_outcomes'];
}
if (! is_array($results)) {
return [];
}
return collect($results)
->pluck('assignment_outcomes')
->flatten(1)
->filter()
->values()
->all();
}
public function getSuccessfulAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn ($outcome) => $outcome['status'] === 'success'
));
}
public function getFailedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn ($outcome) => $outcome['status'] === 'failed'
));
}
public function getSkippedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn ($outcome) => $outcome['status'] === 'skipped'
));
}
} }

View File

@ -0,0 +1,194 @@
<?php
namespace App\Services;
use App\Models\BackupItem;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Support\Facades\Log;
class AssignmentBackupService
{
public function __construct(
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
private readonly ScopeTagResolver $scopeTagResolver,
) {}
/**
* Enrich a backup item with assignments and scope tag metadata.
*
* @param BackupItem $backupItem The backup item to enrich
* @param Tenant $tenant Tenant model with credentials
* @param string $policyId Policy ID (external_id from Graph)
* @param array $policyPayload Full policy payload from Graph
* @param bool $includeAssignments Whether to fetch and include assignments
* @return BackupItem Updated backup item with assignments and metadata
*/
public function enrichWithAssignments(
BackupItem $backupItem,
Tenant $tenant,
string $policyId,
array $policyPayload,
bool $includeAssignments = false
): BackupItem {
// Extract scope tags from payload (always available in policy)
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0'];
$scopeTagNames = $this->resolveScopeTagNames($scopeTagIds, $tenant);
$metadata = $backupItem->metadata ?? [];
$metadata['scope_tag_ids'] = $scopeTagIds;
$metadata['scope_tag_names'] = $scopeTagNames;
// Only fetch assignments if explicitly requested
if (! $includeAssignments) {
$metadata['assignment_count'] = 0;
$backupItem->update([
'assignments' => null,
'metadata' => $metadata,
]);
return $backupItem->refresh();
}
// Fetch assignments from Graph API
$graphOptions = $tenant->graphOptions();
$tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id;
$assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions);
if (empty($assignments)) {
// No assignments or fetch failed
$metadata['assignment_count'] = 0;
$metadata['assignments_fetch_failed'] = true;
$metadata['has_orphaned_assignments'] = false;
$backupItem->update([
'assignments' => [], // Return empty array instead of null
'metadata' => $metadata,
]);
Log::warning('No assignments fetched for policy', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'backup_item_id' => $backupItem->id,
]);
return $backupItem->refresh();
}
// Extract group IDs and resolve for orphan detection
$groupIds = $this->extractGroupIds($assignments);
$resolvedGroups = [];
$hasOrphanedGroups = false;
if (! empty($groupIds)) {
$resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantId, $graphOptions);
$hasOrphanedGroups = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($assignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($assignments, $resolvedGroups, $filterNames);
// Update backup item with assignments and metadata
$metadata['assignment_count'] = count($assignments);
$metadata['assignments_fetch_failed'] = false;
$metadata['has_orphaned_assignments'] = $hasOrphanedGroups;
$backupItem->update([
'assignments' => $assignments,
'metadata' => $metadata,
]);
Log::info('Assignments enriched for backup item', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'backup_item_id' => $backupItem->id,
'assignment_count' => count($assignments),
'has_orphaned' => $hasOrphanedGroups,
]);
return $backupItem->refresh();
}
/**
* Resolve scope tag IDs to display names.
*/
private function resolveScopeTagNames(array $scopeTagIds, Tenant $tenant): array
{
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant);
$names = [];
foreach ($scopeTagIds as $id) {
$scopeTag = collect($scopeTags)->firstWhere('id', $id);
$names[] = $scopeTag['displayName'] ?? "Unknown (ID: {$id})";
}
return $names;
}
/**
* Extract group IDs from assignment array.
*/
private function extractGroupIds(array $assignments): array
{
$groupIds = [];
foreach ($assignments as $assignment) {
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true) && isset($target['groupId'])) {
$groupIds[] = $target['groupId'];
}
}
return array_unique($groupIds);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
private function enrichAssignments(array $assignments, array $groups, array $filterNames): array
{
return array_map(function (array $assignment) use ($groups, $filterNames): array {
$target = $assignment['target'] ?? [];
$groupId = $target['groupId'] ?? null;
if ($groupId && isset($groups[$groupId])) {
$target['group_display_name'] = $groups[$groupId]['displayName'] ?? null;
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
$assignment['target'] = $target;
return $assignment;
}, $assignments);
}
}

View File

@ -0,0 +1,466 @@
<?php
namespace App\Services;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class AssignmentRestoreService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphContractRegistry $contracts,
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
) {}
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, string> $groupMapping
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
*/
public function restore(
Tenant $tenant,
string $policyType,
string $policyId,
array $assignments,
array $groupMapping,
?RestoreRun $restoreRun = null,
?string $actorEmail = null,
?string $actorName = null,
): array {
$outcomes = [];
$summary = [
'success' => 0,
'failed' => 0,
'skipped' => 0,
];
if ($assignments === []) {
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$contract = $this->contracts->get($policyType);
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) {
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
$summary['failed']++;
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$context = [
'tenant' => $tenantIdentifier,
'policy_id' => $policyId,
'policy_type' => $policyType,
'restore_run_id' => $restoreRun?->id,
];
$preparedAssignments = [];
$preparedMeta = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$groupId = $assignment['target']['groupId'] ?? null;
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
if ($mappedGroupId === 'SKIP') {
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
$summary['skipped']++;
$this->logAssignmentOutcome(
status: 'skipped',
tenant: $tenant,
assignment: $assignment,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
]
);
continue;
}
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
$preparedAssignments[] = $assignmentToRestore;
$preparedMeta[] = [
'assignment' => $assignment,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
if ($preparedAssignments === []) {
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
if ($usesAssignAction) {
$this->graphLogger->logRequest('restore_assignments_assign', $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => ['assignments' => $preparedAssignments],
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'assignments' => count($preparedAssignments),
]);
if ($assignResponse->successful()) {
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->successOutcome(
$meta['assignment'],
$meta['group_id'],
$meta['mapped_group_id']
);
$summary['success']++;
$this->logAssignmentOutcome(
status: 'created',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]
);
}
} else {
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
if ($preparedMeta === []) {
$outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse);
$summary['failed']++;
}
foreach ($preparedMeta as $meta) {
$outcomes[] = $this->failureOutcome(
$meta['assignment'],
$reason,
$meta['group_id'],
$meta['mapped_group_id'],
$assignResponse
);
$summary['failed']++;
$this->logAssignmentOutcome(
status: 'failed',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $assignResponse->meta['error_message'] ?? null,
'graph_error_code' => $assignResponse->meta['error_code'] ?? null,
],
);
}
}
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
$this->graphLogger->logRequest('restore_assignments_list', $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$response = $this->graphClient->request('GET', $listPath, $graphOptions);
$this->graphLogger->logResponse('restore_assignments_list', $response, $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existingAssignments = $response->data['value'] ?? [];
foreach ($existingAssignments as $existing) {
$assignmentId = $existing['id'] ?? null;
if (! is_string($assignmentId) || $assignmentId === '') {
continue;
}
$deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId);
if (! $deletePath) {
continue;
}
$this->graphLogger->logRequest('restore_assignments_delete', $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'assignment_id' => $assignmentId,
]);
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
$this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'assignment_id' => $assignmentId,
]);
if ($deleteResponse->failed()) {
Log::warning('Failed to delete existing assignment during restore', $context + [
'assignment_id' => $assignmentId,
'graph_error_message' => $deleteResponse->meta['error_message'] ?? null,
'graph_error_code' => $deleteResponse->meta['error_code'] ?? null,
]);
}
}
foreach ($preparedMeta as $index => $meta) {
$assignmentToRestore = $preparedAssignments[$index] ?? null;
if (! is_array($assignmentToRestore)) {
continue;
}
$this->graphLogger->logRequest('restore_assignments_create', $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]);
$createResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => $assignmentToRestore,
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
'method' => $createMethod,
'endpoint' => $createPath,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]);
if ($createResponse->successful()) {
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
$summary['success']++;
$this->logAssignmentOutcome(
status: 'created',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
]
);
} else {
$outcomes[] = $this->failureOutcome(
$meta['assignment'],
$createResponse->meta['error_message'] ?? 'Graph create failed',
$meta['group_id'],
$meta['mapped_group_id'],
$createResponse
);
$summary['failed']++;
$this->logAssignmentOutcome(
status: 'failed',
tenant: $tenant,
assignment: $meta['assignment'],
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
metadata: [
'policy_id' => $policyId,
'policy_type' => $policyType,
'group_id' => $meta['group_id'],
'mapped_group_id' => $meta['mapped_group_id'],
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
],
);
}
usleep(100000);
}
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string
{
if (! is_string($template) || $template === '') {
return null;
}
$path = str_replace('{id}', urlencode($policyId), $template);
if ($assignmentId !== null) {
$path = str_replace('{assignmentId}', urlencode($assignmentId), $path);
}
return $path;
}
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
{
if (! $mappedGroupId) {
return $assignment;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true) && isset($target['groupId'])) {
$target['groupId'] = $mappedGroupId;
$assignment['target'] = $target;
}
return $assignment;
}
private function sanitizeAssignment(array $assignment): array
{
$assignment = Arr::except($assignment, ['id']);
$target = $assignment['target'] ?? [];
unset(
$target['group_display_name'],
$target['group_orphaned'],
$target['assignment_filter_name']
);
$assignment['target'] = $target;
return $assignment;
}
private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
{
return [
'status' => 'success',
'assignment' => $this->sanitizeAssignment($assignment),
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
{
return [
'status' => 'skipped',
'assignment' => $this->sanitizeAssignment($assignment),
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
];
}
private function failureOutcome(
?array $assignment,
string $reason,
?string $groupId = null,
?string $mappedGroupId = null,
?GraphResponse $response = null
): array {
return array_filter([
'status' => 'failed',
'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null,
'group_id' => $groupId,
'mapped_group_id' => $mappedGroupId,
'reason' => $reason,
'graph_error_message' => $response?->meta['error_message'] ?? null,
'graph_error_code' => $response?->meta['error_code'] ?? null,
'graph_request_id' => $response?->meta['request_id'] ?? null,
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
], static fn ($value) => $value !== null);
}
private function logAssignmentOutcome(
string $status,
Tenant $tenant,
array $assignment,
?RestoreRun $restoreRun,
?string $actorEmail,
?string $actorName,
array $metadata
): void {
$action = match ($status) {
'created' => 'restore.assignment.created',
'failed' => 'restore.assignment.failed',
default => 'restore.assignment.skipped',
};
$statusLabel = match ($status) {
'created' => 'success',
'failed' => 'failed',
default => 'warning',
};
$this->auditLogger->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => $metadata,
'assignment' => $this->sanitizeAssignment($assignment),
],
actorEmail: $actorEmail,
actorName: $actorName,
status: $statusLabel,
resourceType: 'restore_run',
resourceId: $restoreRun ? (string) $restoreRun->id : null
);
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Services\Graph;
use Illuminate\Support\Facades\Log;
class AssignmentFetcher
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
) {}
/**
* Fetch policy assignments with fallback strategy.
*
* Primary: GET /deviceManagement/configurationPolicies/{id}/assignments
* Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'
*
* @return array Returns assignment array or empty array on failure
*/
public function fetch(string $tenantId, string $policyId, array $options = []): array
{
try {
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
// Try primary endpoint
$assignments = $this->fetchPrimary($policyId, $requestOptions);
if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'count' => count($assignments),
]);
return $assignments;
}
// Try fallback with $expand
Log::debug('Primary endpoint returned empty, trying fallback', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
]);
$assignments = $this->fetchWithExpand($policyId, $requestOptions);
if (! empty($assignments)) {
Log::debug('Fetched assignments via fallback endpoint', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'count' => count($assignments),
]);
return $assignments;
}
// Both methods returned empty
Log::debug('No assignments found for policy', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
]);
return [];
} catch (GraphException $e) {
Log::warning('Failed to fetch assignments', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'error' => $e->getMessage(),
'context' => $e->context,
]);
return [];
}
}
/**
* Fetch assignments using primary endpoint.
*/
private function fetchPrimary(string $policyId, array $options): array
{
$path = "/deviceManagement/configurationPolicies/{$policyId}/assignments";
$response = $this->graphClient->request('GET', $path, $options);
return $response->data['value'] ?? [];
}
/**
* Fetch assignments using $expand fallback.
*/
private function fetchWithExpand(string $policyId, array $options): array
{
$path = '/deviceManagement/configurationPolicies';
$params = [
'$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'",
];
$response = $this->graphClient->request('GET', $path, array_merge($options, [
'query' => $params,
]));
$policies = $response->data['value'] ?? [];
if (empty($policies)) {
return [];
}
return $policies[0]['assignments'] ?? [];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Graph;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
class AssignmentFilterResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
) {}
/**
* @param array<int, string> $filterIds
* @return array<int, array{id:string,displayName:?string}>
*/
public function resolve(array $filterIds, ?Tenant $tenant = null): array
{
if (empty($filterIds)) {
return [];
}
$allFilters = $this->fetchAllFilters($tenant);
return array_values(array_filter($allFilters, function (array $filter) use ($filterIds): bool {
return in_array($filter['id'] ?? null, $filterIds, true);
}));
}
/**
* @return array<int, array{id:string,displayName:?string}>
*/
private function fetchAllFilters(?Tenant $tenant = null): array
{
$cacheKey = $tenant ? "assignment_filters:tenant:{$tenant->id}" : 'assignment_filters:all';
return Cache::remember($cacheKey, 3600, function () use ($tenant): array {
$options = ['query' => ['$select' => 'id,displayName']];
if ($tenant) {
$options = array_merge($options, $tenant->graphOptions());
}
$response = $this->graphClient->request(
'GET',
'/deviceManagement/assignmentFilters',
$options
);
return $response->data['value'] ?? [];
});
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Services\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class GroupResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
) {}
/**
* Resolve group IDs to group objects with display names.
*
* Uses POST /directoryObjects/getByIds endpoint.
* Missing IDs are marked as orphaned.
*
* @param array $groupIds Array of group IDs to resolve
* @param string $tenantId Target tenant ID
* @return array Keyed array: ['group-id' => ['id' => ..., 'displayName' => ..., 'orphaned' => bool]]
*/
public function resolveGroupIds(array $groupIds, string $tenantId, array $options = []): array
{
if (empty($groupIds)) {
return [];
}
// Create cache key
$cacheKey = $this->getCacheKey($groupIds, $tenantId);
return Cache::remember($cacheKey, 300, function () use ($groupIds, $tenantId, $options) {
return $this->fetchAndResolveGroups($groupIds, $tenantId, $options);
});
}
/**
* Fetch groups from Graph API and resolve orphaned IDs.
*/
private function fetchAndResolveGroups(array $groupIds, string $tenantId, array $options = []): array
{
try {
$response = $this->graphClient->request(
'POST',
'/directoryObjects/getByIds',
array_merge($options, [
'tenant' => $tenantId,
'json' => [
'ids' => array_values($groupIds),
'types' => ['group'],
],
])
);
$resolvedGroups = $response->data['value'] ?? [];
// Create result map
$result = [];
$resolvedIds = [];
// Add resolved groups
foreach ($resolvedGroups as $group) {
$groupId = $group['id'];
$resolvedIds[] = $groupId;
$result[$groupId] = [
'id' => $groupId,
'displayName' => $group['displayName'] ?? null,
'orphaned' => false,
];
}
// Add orphaned groups (not in response)
foreach ($groupIds as $groupId) {
if (! in_array($groupId, $resolvedIds)) {
$result[$groupId] = [
'id' => $groupId,
'displayName' => null,
'orphaned' => true,
];
}
}
Log::debug('Resolved group IDs', [
'tenant_id' => $tenantId,
'requested' => count($groupIds),
'resolved' => count($resolvedIds),
'orphaned' => count($groupIds) - count($resolvedIds),
]);
return $result;
} catch (GraphException $e) {
Log::warning('Failed to resolve group IDs', [
'tenant_id' => $tenantId,
'group_ids' => $groupIds,
'error' => $e->getMessage(),
'context' => $e->context,
]);
// Return all as orphaned on failure
$result = [];
foreach ($groupIds as $groupId) {
$result[$groupId] = [
'id' => $groupId,
'displayName' => null,
'orphaned' => true,
];
}
return $result;
}
}
/**
* Generate cache key for group resolution.
*/
private function getCacheKey(array $groupIds, string $tenantId): string
{
sort($groupIds);
return "groups:{$tenantId}:".md5(implode(',', $groupIds));
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Services\Graph;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
class ScopeTagResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphLogger $logger,
) {}
/**
* Resolve scope tag IDs to scope tag objects.
*
* Fetches all scope tags from tenant and caches for 1 hour.
* Filters to requested IDs in memory.
*
* @param array $scopeTagIds Array of scope tag IDs to resolve
* @param Tenant|null $tenant Optional tenant model with credentials
* @return array Array of scope tag objects with id and displayName
*/
public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
{
if (empty($scopeTagIds)) {
return [];
}
// Fetch all scope tags (cached)
$allScopeTags = $this->fetchAllScopeTags($tenant);
// Filter to requested IDs
return array_filter($allScopeTags, function ($scopeTag) use ($scopeTagIds) {
return in_array($scopeTag['id'], $scopeTagIds);
});
}
/**
* Fetch all scope tags from Graph API (cached for 1 hour).
*/
private function fetchAllScopeTags(?Tenant $tenant = null): array
{
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
try {
$options = ['query' => ['$select' => 'id,displayName']];
// Add tenant credentials if provided
if ($tenant) {
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
$options['client_id'] = $tenant->app_client_id;
$options['client_secret'] = $tenant->app_client_secret;
}
$graphResponse = $this->graphClient->request(
'GET',
'/deviceManagement/roleScopeTags',
$options
);
$scopeTags = $graphResponse->data['value'] ?? [];
// Check for 403 Forbidden (missing permissions)
if (! $graphResponse->success && $graphResponse->status === 403) {
\Log::warning('Scope tag fetch failed: Missing permissions', [
'tenant_id' => $tenant?->id,
'status' => 403,
'required_permissions' => ['DeviceManagementRBAC.Read.All', 'DeviceManagementRBAC.ReadWrite.All'],
'message' => 'App registration needs DeviceManagementRBAC permissions to read scope tags',
]);
}
// Success - return scope tags
return $scopeTags;
} catch (\Exception $e) {
// Fail soft - return empty array on any error
\Log::warning('Scope tag fetch exception', [
'tenant_id' => $tenant?->id,
'error' => $e->getMessage(),
]);
return [];
}
});
}
}

View File

@ -6,6 +6,7 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\AssignmentBackupService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -16,6 +17,8 @@ public function __construct(
private readonly VersionService $versionService, private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator, private readonly SnapshotValidator $snapshotValidator,
private readonly PolicySnapshotService $snapshotService, private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentBackupService $assignmentBackupService,
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
) {} ) {}
/** /**
@ -29,6 +32,8 @@ public function createBackupSet(
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
?string $name = null, ?string $name = null,
bool $includeAssignments = false,
bool $includeScopeTags = false,
): BackupSet { ): BackupSet {
$this->assertActiveTenant($tenant); $this->assertActiveTenant($tenant);
@ -37,7 +42,7 @@ public function createBackupSet(
->whereIn('id', $policyIds) ->whereIn('id', $policyIds)
->get(); ->get();
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) { $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags) {
$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',
@ -50,7 +55,14 @@ public function createBackupSet(
$itemsCreated = 0; $itemsCreated = 0;
foreach ($policies as $policy) { foreach ($policies as $policy) {
[$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); [$item, $failure] = $this->snapshotPolicy(
$tenant,
$backupSet,
$policy,
$actorEmail,
$includeAssignments,
$includeScopeTags
);
if ($failure !== null) { if ($failure !== null) {
$failures[] = $failure; $failures[] = $failure;
@ -92,6 +104,31 @@ public function createBackupSet(
status: $backupSet->status === 'completed' ? 'success' : 'partial' status: $backupSet->status === 'completed' ? 'success' : 'partial'
); );
// Log if assignments were included
if ($includeAssignments) {
$items = $backupSet->items;
$assignmentCount = $items->sum(function ($item) {
return $item->metadata['assignment_count'] ?? 0;
});
$this->auditLogger->log(
tenant: $tenant,
action: 'backup.assignments.included',
context: [
'metadata' => [
'backup_set_id' => $backupSet->id,
'policy_count' => $backupSet->item_count,
'assignment_count' => $assignmentCount,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'backup_set',
resourceId: (string) $backupSet->id,
status: 'success'
);
}
return $backupSet; return $backupSet;
} }
@ -106,6 +143,8 @@ public function addPoliciesToSet(
array $policyIds, array $policyIds,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
bool $includeAssignments = false,
bool $includeScopeTags = false,
): BackupSet { ): BackupSet {
$this->assertActiveTenant($tenant); $this->assertActiveTenant($tenant);
@ -114,9 +153,20 @@ public function addPoliciesToSet(
} }
$existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); $existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
// Separate into truly new policies and soft-deleted ones to restore
$softDeletedItems = $backupSet->items()->onlyTrashed()->whereIn('policy_id', $policyIds)->get();
$softDeletedPolicyIds = $softDeletedItems->pluck('policy_id')->all();
// Restore soft-deleted items
foreach ($softDeletedItems as $item) {
$item->restore();
}
// Only create new items for policies that don't exist at all
$policyIds = array_values(array_diff($policyIds, $existingPolicyIds)); $policyIds = array_values(array_diff($policyIds, $existingPolicyIds));
if (empty($policyIds)) { if (empty($policyIds) && $softDeletedItems->isEmpty()) {
return $backupSet->refresh(); return $backupSet->refresh();
} }
@ -130,7 +180,14 @@ public function addPoliciesToSet(
$itemsCreated = 0; $itemsCreated = 0;
foreach ($policies as $policy) { foreach ($policies as $policy) {
[$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); [$item, $failure] = $this->snapshotPolicy(
$tenant,
$backupSet,
$policy,
$actorEmail,
$includeAssignments,
$includeScopeTags
);
if ($failure !== null) { if ($failure !== null) {
$failures[] = $failure; $failures[] = $failure;
@ -159,6 +216,7 @@ public function addPoliciesToSet(
'metadata' => [ 'metadata' => [
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'added_count' => $itemsCreated, 'added_count' => $itemsCreated,
'restored_count' => $softDeletedItems->count(),
'status' => $status, 'status' => $status,
], ],
], ],
@ -184,18 +242,39 @@ private function resolveStatus(int $itemsCreated, array $failures): string
/** /**
* @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}} * @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}}
*/ */
private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array private function snapshotPolicy(
{ Tenant $tenant,
$snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail); BackupSet $backupSet,
Policy $policy,
?string $actorEmail = null,
bool $includeAssignments = false,
bool $includeScopeTags = false
): array {
// Use orchestrator to capture policy + assignments into PolicyVersion first
$captureResult = $this->captureOrchestrator->capture(
policy: $policy,
tenant: $tenant,
includeAssignments: $includeAssignments,
includeScopeTags: $includeScopeTags,
createdBy: $actorEmail,
metadata: [
'source' => 'backup',
'backup_set_id' => $backupSet->id,
]
);
if (isset($snapshot['failure'])) { // Check for capture failure
return [null, $snapshot['failure']]; if (isset($captureResult['failure'])) {
return [null, $captureResult['failure']];
} }
$payload = $snapshot['payload']; $version = $captureResult['version'];
$metadata = $snapshot['metadata'] ?? []; $captured = $captureResult['captured'];
$metadataWarnings = $snapshot['warnings'] ?? []; $payload = $captured['payload'];
$metadata = $captured['metadata'] ?? [];
$metadataWarnings = $captured['warnings'] ?? [];
// Validate snapshot
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
@ -209,28 +288,22 @@ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $po
$metadata['warnings'] = array_values(array_unique($metadataWarnings)); $metadata['warnings'] = array_values(array_unique($metadataWarnings));
} }
// Create BackupItem as a copy/reference of the PolicyVersion
$backupItem = BackupItem::create([ $backupItem = BackupItem::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'policy_version_id' => $version->id, // Link to version
'policy_identifier' => $policy->external_id, 'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
'payload' => $payload, 'payload' => $payload,
'metadata' => $metadata, 'metadata' => $metadata,
// Copy assignments from version (already captured)
// Note: scope_tags are only stored in PolicyVersion
'assignments' => $captured['assignments'] ?? null,
]); ]);
$this->versionService->captureVersion(
policy: $policy,
payload: $payload,
createdBy: $actorEmail,
metadata: [
'source' => 'backup',
'backup_set_id' => $backupSet->id,
'backup_item_id' => $backupItem->id,
]
);
return [$backupItem, null]; return [$backupItem, null];
} }

View File

@ -0,0 +1,369 @@
<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Support\Facades\Log;
/**
* Orchestrates policy capture with assignments and scope tags.
*
* Ensures PolicyVersion is the source of truth, with BackupItem as restore copy.
*/
class PolicyCaptureOrchestrator
{
public function __construct(
private readonly VersionService $versionService,
private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
private readonly ScopeTagResolver $scopeTagResolver,
) {}
/**
* Capture policy snapshot with optional assignments/scope tags.
*
* @return array ['version' => PolicyVersion, 'captured' => array]
*/
public function capture(
Policy $policy,
Tenant $tenant,
bool $includeAssignments = false,
bool $includeScopeTags = false,
?string $createdBy = null,
array $metadata = []
): array {
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
// 1. Fetch policy snapshot
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) {
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot');
}
$payload = $snapshot['payload'];
$assignments = null;
$scopeTags = null;
$captureMetadata = [];
// 2. Fetch assignments if requested
if ($includeAssignments) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
// Resolve groups for orphaned detection
$groupIds = collect($rawAssignments)
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
if (! empty($groupIds)) {
$resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$captureMetadata['has_orphaned_assignments'] = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
$captureMetadata['assignments_count'] = count($rawAssignments);
}
} catch (\Throwable $e) {
$captureMetadata['assignments_fetch_failed'] = true;
$captureMetadata['assignments_fetch_error'] = $e->getMessage();
Log::warning('Failed to fetch assignments during capture', [
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'error' => $e->getMessage(),
]);
}
}
// 3. Fetch scope tags if requested
if ($includeScopeTags) {
$scopeTagIds = $payload['roleScopeTagIds'] ?? ['0'];
$scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds);
}
// 4. Check if PolicyVersion with same snapshot already exists
$snapshotHash = hash('sha256', json_encode($payload));
// Find existing version by comparing snapshot content (database-agnostic)
$existingVersion = PolicyVersion::where('policy_id', $policy->id)
->get()
->first(function ($version) use ($snapshotHash) {
return hash('sha256', json_encode($version->snapshot)) === $snapshotHash;
});
if ($existingVersion) {
$updates = [];
if ($includeAssignments && $existingVersion->assignments === null) {
$updates['assignments'] = $assignments;
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
}
if ($includeScopeTags && $existingVersion->scope_tags === null) {
$updates['scope_tags'] = $scopeTags;
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null;
}
if (! empty($updates)) {
$existingVersion->update($updates);
Log::info('Backfilled existing PolicyVersion with capture data', [
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_id' => $existingVersion->id,
'version_number' => $existingVersion->version_number,
'assignments_backfilled' => array_key_exists('assignments', $updates),
'scope_tags_backfilled' => array_key_exists('scope_tags', $updates),
]);
return [
'version' => $existingVersion->fresh(),
'captured' => [
'payload' => $payload,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'metadata' => $captureMetadata,
],
];
}
Log::info('Reusing existing PolicyVersion', [
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_id' => $existingVersion->id,
'version_number' => $existingVersion->version_number,
]);
return [
'version' => $existingVersion,
'captured' => [
'payload' => $payload,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'metadata' => $captureMetadata,
],
];
}
// 5. Create new PolicyVersion with all captured data
$metadata = array_merge(
['source' => 'orchestrated_capture'],
$metadata,
$captureMetadata
);
$version = $this->versionService->captureVersion(
policy: $policy,
payload: $payload,
createdBy: $createdBy,
metadata: $metadata,
assignments: $assignments,
scopeTags: $scopeTags,
);
Log::info('Policy captured via orchestrator', [
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_id' => $version->id,
'version_number' => $version->version_number,
'has_assignments' => ! is_null($assignments),
'has_scope_tags' => ! is_null($scopeTags),
]);
return [
'version' => $version,
'captured' => [
'payload' => $payload,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'metadata' => $captureMetadata,
],
];
}
/**
* Ensure existing PolicyVersion has assignments if missing.
*/
public function ensureVersionHasAssignments(
PolicyVersion $version,
Tenant $tenant,
Policy $policy,
bool $includeAssignments = false,
bool $includeScopeTags = false
): PolicyVersion {
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
if ($version->assignments !== null && $version->scope_tags !== null) {
Log::debug('Version already has assignments, skipping', [
'version_id' => $version->id,
]);
return $version;
}
// Only fetch if requested
if (! $includeAssignments && ! $includeScopeTags) {
return $version;
}
$assignments = null;
$scopeTags = null;
$metadata = $version->metadata ?? [];
if ($includeAssignments && $version->assignments === null) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
// Resolve groups
$groupIds = collect($rawAssignments)
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
if (! empty($groupIds)) {
$resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$metadata['has_orphaned_assignments'] = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
$metadata['assignments_count'] = count($rawAssignments);
}
} catch (\Throwable $e) {
$metadata['assignments_fetch_failed'] = true;
$metadata['assignments_fetch_error'] = $e->getMessage();
Log::warning('Failed to backfill assignments for version', [
'version_id' => $version->id,
'error' => $e->getMessage(),
]);
}
}
// Fetch scope tags
if ($includeScopeTags && $version->scope_tags === null) {
$scopeTagIds = $version->snapshot['roleScopeTagIds'] ?? ['0'];
$scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds);
}
$updates = [];
if ($includeAssignments && $version->assignments === null) {
$updates['assignments'] = $assignments;
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
}
if ($includeScopeTags && $version->scope_tags === null) {
$updates['scope_tags'] = $scopeTags;
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null;
}
if (! empty($updates)) {
$updates['metadata'] = $metadata;
$version->update($updates);
}
Log::info('Version backfilled with capture data', [
'version_id' => $version->id,
'has_assignments' => ! is_null($assignments),
'has_scope_tags' => ! is_null($scopeTags),
]);
return $version->refresh();
}
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
private function enrichAssignments(array $assignments, array $groups, array $filterNames): array
{
return array_map(function (array $assignment) use ($groups, $filterNames): array {
$target = $assignment['target'] ?? [];
$groupId = $target['groupId'] ?? null;
if ($groupId && isset($groups[$groupId])) {
$target['group_display_name'] = $groups[$groupId]['displayName'] ?? null;
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
$assignment['target'] = $target;
return $assignment;
}, $assignments);
}
/**
* @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}
*/
private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array
{
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant);
$names = [];
foreach ($scopeTagIds as $id) {
$scopeTag = collect($scopeTags)->firstWhere('id', $id);
$names[] = $scopeTag['displayName'] ?? ($id === '0' ? 'Default' : "Unknown (ID: {$id})");
}
return [
'ids' => $scopeTagIds,
'names' => $names,
];
}
}

View File

@ -7,6 +7,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphErrorMapper;
@ -25,6 +26,7 @@ public function __construct(
private readonly VersionService $versionService, private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator, private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly AssignmentRestoreService $assignmentRestoreService,
) {} ) {}
/** /**
@ -73,6 +75,7 @@ public function execute(
bool $dryRun = true, bool $dryRun = true,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [],
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -90,8 +93,28 @@ public function execute(
'preview' => $preview, 'preview' => $preview,
'started_at' => CarbonImmutable::now(), 'started_at' => CarbonImmutable::now(),
'metadata' => [], 'metadata' => [],
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
if ($groupMapping !== []) {
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.group_mapping.applied',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'mapped_groups' => count($groupMapping),
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success'
);
}
$results = []; $results = [];
$hardFailures = 0; $hardFailures = 0;
@ -265,6 +288,31 @@ public function execute(
continue; continue;
} }
$assignmentOutcomes = null;
$assignmentSummary = null;
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
$assignmentOutcomes = $this->assignmentRestoreService->restore(
tenant: $tenant,
policyType: $item->policy_type,
policyId: $assignmentPolicyId,
assignments: $item->assignments,
groupMapping: $groupMapping,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
);
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Assignments restored with failures';
}
}
$result = $context + ['status' => $itemStatus]; $result = $context + ['status' => $itemStatus];
if ($settingsApply !== null) { if ($settingsApply !== null) {
@ -285,6 +333,14 @@ public function execute(
$result['reason'] = 'Some settings require attention'; $result['reason'] = 'Some settings require attention';
} }
if ($assignmentOutcomes !== null) {
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
}
if ($assignmentSummary !== null) {
$result['assignment_summary'] = $assignmentSummary;
}
$results[] = $result; $results[] = $result;
$appliedPolicyId = $item->policy_identifier; $appliedPolicyId = $item->policy_identifier;

View File

@ -105,7 +105,7 @@ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $pe
$overall = match (true) { $overall = match (true) {
$hasErrors => 'error', $hasErrors => 'error',
$hasMissing => 'missing', $hasMissing => 'missing',
default => 'ok', default => 'granted',
}; };
return [ return [
@ -148,7 +148,7 @@ public function configuredGrantedStatuses(): array
foreach ($configured as $key) { foreach ($configured as $key) {
$normalized[$key] = [ $normalized[$key] = [
'status' => 'ok', 'status' => 'granted',
'details' => ['source' => 'configured'], 'details' => ['source' => 'configured'],
]; ];
} }
@ -204,7 +204,7 @@ private function fetchLivePermissions(Tenant $tenant): array
foreach ($grantedPermissions as $permission) { foreach ($grantedPermissions as $permission) {
$normalized[$permission] = [ $normalized[$permission] = [
'status' => 'ok', 'status' => 'granted',
'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()], 'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()],
]; ];
} }

View File

@ -5,6 +5,10 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class VersionService class VersionService
@ -12,6 +16,10 @@ class VersionService
public function __construct( public function __construct(
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly PolicySnapshotService $snapshotService, private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
private readonly ScopeTagResolver $scopeTagResolver,
) {} ) {}
public function captureVersion( public function captureVersion(
@ -19,6 +27,8 @@ public function captureVersion(
array $payload, array $payload,
?string $createdBy = null, ?string $createdBy = null,
array $metadata = [], array $metadata = [],
?array $assignments = null,
?array $scopeTags = null,
): PolicyVersion { ): PolicyVersion {
$versionNumber = $this->nextVersionNumber($policy); $versionNumber = $this->nextVersionNumber($policy);
@ -32,6 +42,10 @@ public function captureVersion(
'captured_at' => CarbonImmutable::now(), 'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload, 'snapshot' => $payload,
'metadata' => $metadata, 'metadata' => $metadata,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
]); ]);
$this->auditLogger->log( $this->auditLogger->log(
@ -56,7 +70,12 @@ public function captureFromGraph(
Policy $policy, Policy $policy,
?string $createdBy = null, ?string $createdBy = null,
array $metadata = [], array $metadata = [],
bool $includeAssignments = true,
bool $includeScopeTags = true,
): PolicyVersion { ): PolicyVersion {
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) { if (isset($snapshot['failure'])) {
@ -65,16 +84,123 @@ public function captureFromGraph(
throw new \RuntimeException($reason); throw new \RuntimeException($reason);
} }
$metadata = array_merge(['source' => 'version_capture'], $metadata); $payload = $snapshot['payload'];
$assignments = null;
$scopeTags = null;
$assignmentMetadata = [];
if ($includeAssignments) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
// Resolve groups
$groupIds = collect($rawAssignments)
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
if (! empty($groupIds)) {
$resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
}
$assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false);
$assignmentMetadata['assignments_count'] = count($rawAssignments);
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
->pluck('displayName', 'id')
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
}
} catch (\Throwable $e) {
$assignmentMetadata['assignments_fetch_failed'] = true;
$assignmentMetadata['assignments_fetch_error'] = $e->getMessage();
}
}
if ($includeScopeTags) {
$scopeTagIds = $payload['roleScopeTagIds'] ?? ['0'];
$scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds);
}
$metadata = array_merge(
['source' => 'version_capture'],
$metadata,
$assignmentMetadata
);
return $this->captureVersion( return $this->captureVersion(
policy: $policy, policy: $policy,
payload: $snapshot['payload'], payload: $payload,
createdBy: $createdBy, createdBy: $createdBy,
metadata: $metadata, metadata: $metadata,
assignments: $assignments,
scopeTags: $scopeTags,
); );
} }
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
private function enrichAssignments(array $assignments, array $groups, array $filterNames): array
{
return array_map(function (array $assignment) use ($groups, $filterNames): array {
$target = $assignment['target'] ?? [];
$groupId = $target['groupId'] ?? null;
if ($groupId && isset($groups[$groupId])) {
$target['group_display_name'] = $groups[$groupId]['displayName'] ?? null;
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
$assignment['target'] = $target;
return $assignment;
}, $assignments);
}
/**
* @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}
*/
private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array
{
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant);
$names = [];
foreach ($scopeTagIds as $id) {
$scopeTag = collect($scopeTags)->firstWhere('id', $id);
$names[] = $scopeTag['displayName'] ?? ($id === '0' ? 'Default' : "Unknown (ID: {$id})");
}
return [
'ids' => $scopeTagIds,
'names' => $names,
];
}
private function nextVersionNumber(Policy $policy): int private function nextVersionNumber(Policy $policy): int
{ {
$current = PolicyVersion::query() $current = PolicyVersion::query()

View File

@ -70,6 +70,19 @@
'fallback_body_shape' => 'wrapped', 'fallback_body_shape' => 'wrapped',
], ],
'update_strategy' => 'settings_catalog_policy_with_settings', 'update_strategy' => 'settings_catalog_policy_with_settings',
// Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
// Scope Tags
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
], ],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',

View File

@ -56,6 +56,18 @@
'description' => 'Read directory data needed for tenant health checks.', 'description' => 'Read directory data needed for tenant health checks.',
'features' => ['tenant-health'], 'features' => ['tenant-health'],
], ],
[
'key' => 'DeviceManagementRBAC.Read.All',
'type' => 'application',
'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.',
'features' => ['scope-tags', 'backup-metadata', 'assignments'],
],
[
'key' => 'Group.Read.All',
'type' => 'application',
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
'features' => ['assignments', 'group-mapping', 'backup-metadata'],
],
[ [
'key' => 'DeviceManagementScripts.ReadWrite.All', 'key' => 'DeviceManagementScripts.ReadWrite.All',
'type' => 'application', 'type' => 'application',
@ -66,8 +78,14 @@
// Stub list of permissions already granted to the service principal (used for display in Tenant verification UI). // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI).
// Diese Liste sollte mit den tatsächlich in Entra ID granted permissions übereinstimmen. // Diese Liste sollte mit den tatsächlich in Entra ID granted permissions übereinstimmen.
// HINWEIS: In Produktion sollte dies dynamisch von Graph API abgerufen werden (geplant für v1.1+). // HINWEIS: In Produktion sollte dies dynamisch von Graph API abgerufen werden (geplant für v1.1+).
//
// ⚠️ WICHTIG: Nach dem Hinzufügen neuer Berechtigungen in Azure AD:
// 1. Berechtigungen in Azure AD hinzufügen und Admin Consent geben
// 2. Diese Liste unten aktualisieren (von "Required permissions" nach "Tatsächlich granted" verschieben)
// 3. Cache leeren: php artisan cache:clear
// 4. Optional: Live-Check auf Tenant-Detailseite ausführen
'granted_stub' => [ 'granted_stub' => [
// Tatsächlich granted (aus Entra ID Screenshot): // Tatsächlich granted (aus Entra ID):
'Device.Read.All', 'Device.Read.All',
'DeviceManagementConfiguration.Read.All', 'DeviceManagementConfiguration.Read.All',
'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementConfiguration.ReadWrite.All',
@ -77,6 +95,10 @@
'User.Read', 'User.Read',
'DeviceManagementScripts.ReadWrite.All', 'DeviceManagementScripts.ReadWrite.All',
// Feature 004 - Assignments & Scope Tags (granted seit 2025-12-22):
'DeviceManagementRBAC.Read.All', // Scope Tag Namen auflösen
'Group.Read.All', // Group Namen für Assignments auflösen
// Required permissions (müssen in Entra ID granted werden): // Required permissions (müssen in Entra ID granted werden):
// Wenn diese fehlen, erscheinen sie als "missing" in der UI // Wenn diese fehlen, erscheinen sie als "missing" in der UI
'DeviceManagementApps.ReadWrite.All', 'DeviceManagementApps.ReadWrite.All',

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BackupItem>
*/
class BackupItemFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'backup_set_id' => BackupSet::factory(),
'policy_id' => Policy::factory(),
'policy_identifier' => fake()->uuid(),
'policy_type' => 'settingsCatalogPolicy',
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
'captured_at' => now(),
'payload' => ['id' => fake()->uuid(), 'name' => fake()->words(3, true)],
'metadata' => ['policy_name' => fake()->words(3, true)],
'assignments' => null,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BackupSet>
*/
class BackupSetFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'name' => fake()->words(3, true),
'created_by' => fake()->email(),
'status' => 'completed',
'item_count' => fake()->numberBetween(0, 100),
'completed_at' => now(),
'metadata' => [],
];
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -17,12 +18,13 @@ class PolicyFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'tenant_id' => \App\Models\Tenant::factory(), 'tenant_id' => Tenant::factory(),
'external_id' => fake()->uuid(), 'external_id' => fake()->uuid(),
'display_name' => fake()->words(3, true), 'display_name' => fake()->words(3, true),
'policy_type' => 'deviceConfiguration', 'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10AndLater', 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
'metadata' => ['key' => 'value'], 'last_synced_at' => now(),
'metadata' => [],
]; ];
} }
} }

View File

@ -22,8 +22,8 @@ public function definition(): array
'tenant_id' => Tenant::factory(), 'tenant_id' => Tenant::factory(),
'policy_id' => Policy::factory(), 'policy_id' => Policy::factory(),
'version_number' => 1, 'version_number' => 1,
'policy_type' => 'deviceConfiguration', 'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10AndLater', 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
'created_by' => fake()->safeEmail(), 'created_by' => fake()->safeEmail(),
'captured_at' => now(), 'captured_at' => now(),
'snapshot' => ['example' => true], 'snapshot' => ['example' => true],

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\BackupSet;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\RestoreRun>
*/
class RestoreRunFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'backup_set_id' => BackupSet::factory(),
'status' => 'completed',
'is_dry_run' => false,
'requested_items' => [],
'preview' => [],
'results' => [],
'metadata' => [],
'group_mapping' => null,
'started_at' => now()->subHour(),
'completed_at' => now(),
];
}
}

View File

@ -18,10 +18,16 @@ public function definition(): array
{ {
return [ return [
'name' => fake()->company(), 'name' => fake()->company(),
'tenant_id' => fake()->uuid(),
'external_id' => fake()->uuid(), 'external_id' => fake()->uuid(),
'tenant_id' => fake()->uuid(),
'app_client_id' => fake()->uuid(),
'app_client_secret' => null, // Skip encryption in tests
'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null,
'status' => 'active', 'status' => 'active',
'is_current' => true, 'is_current' => false,
'metadata' => [],
]; ];
} }
} }

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->dropColumn('assignments');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->json('group_mapping')->nullable()->after('results');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->dropColumn('group_mapping');
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('policy_versions', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata');
$table->json('scope_tags')->nullable()->after('assignments');
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
$table->index('assignments_hash');
$table->index('scope_tags_hash');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('policy_versions', function (Blueprint $table) {
$table->dropIndex(['assignments_hash']);
$table->dropIndex(['scope_tags_hash']);
$table->dropColumn(['assignments', 'scope_tags', 'assignments_hash', 'scope_tags_hash']);
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->foreignId('policy_version_id')->nullable()->after('policy_id')->constrained('policy_versions')->nullOnDelete();
$table->index('policy_version_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->dropForeign(['policy_version_id']);
$table->dropIndex(['policy_version_id']);
$table->dropColumn('policy_version_id');
});
}
};

154
docs/PERMISSIONS.md Normal file
View File

@ -0,0 +1,154 @@
# Microsoft Graph API Permissions
This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly.
## Required Permissions
The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated):
### Core Policy Management (Required)
- `DeviceManagementConfiguration.Read.All` - Read Intune device configuration policies
- `DeviceManagementConfiguration.ReadWrite.All` - Write/restore Intune policies
- `DeviceManagementApps.Read.All` - Read app configuration policies
- `DeviceManagementApps.ReadWrite.All` - Write app policies
### Scope Tags (Feature 004 - Required for Phase 3)
- **`DeviceManagementRBAC.Read.All`** - Read scope tags and RBAC settings
- **Purpose**: Resolve scope tag IDs to display names (e.g., "0" → "Default")
- **Missing**: Backup items will show "Unknown (ID: 0)" instead of scope tag names
- **Impact**: Metadata display only - backups still work without this permission
### Group Resolution (Feature 004 - Required for Phase 2)
- `Group.Read.All` - Resolve group IDs to names for assignments
- `Directory.Read.All` - Batch resolve directory objects (groups, users, devices)
## How to Add Permissions
### Azure Portal (Entra ID)
1. Go to **Azure Portal****Entra ID** (Azure Active Directory)
2. Navigate to **App registrations** → Select your TenantPilot app
3. Click **API permissions** in the left menu
4. Click **+ Add a permission**
5. Select **Microsoft Graph** → **Application permissions**
6. Search for and select the required permissions:
- `DeviceManagementRBAC.Read.All`
- (Add others as needed)
7. Click **Add permissions**
8. **IMPORTANT**: Click **Grant admin consent for [Your Organization]**
- ⚠️ Without admin consent, the permissions won't be active!
### PowerShell (Alternative)
```powershell
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Get your app registration
$appId = "YOUR-APP-CLIENT-ID"
$app = Get-MgApplication -Filter "appId eq '$appId'"
# Add DeviceManagementRBAC.Read.All permission
$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$rbacPermission = $graphServicePrincipal.AppRoles | Where-Object {$_.Value -eq "DeviceManagementRBAC.Read.All"}
$requiredResourceAccess = @{
ResourceAppId = "00000003-0000-0000-c000-000000000000"
ResourceAccess = @(
@{
Id = $rbacPermission.Id
Type = "Role"
}
)
}
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
# Grant admin consent
# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope)
```
## Verification
After adding permissions and granting admin consent:
1. Go to **App registrations** → Your app → **API permissions**
2. Verify status shows **Granted for [Your Organization]** with a green checkmark ✅
3. Clear cache in TenantPilot:
```bash
php artisan cache:clear
```
4. Test scope tag resolution:
```bash
php artisan tinker
>>> use App\Services\Graph\ScopeTagResolver;
>>> use App\Models\Tenant;
>>> $tenant = Tenant::first();
>>> $resolver = app(ScopeTagResolver::class);
>>> $tags = $resolver->resolve(['0'], $tenant);
>>> dd($tags);
```
Expected output:
```php
[
[
"id" => "0",
"displayName" => "Default"
]
]
```
## Troubleshooting
### Error: "Application is not authorized to perform this operation"
**Symptoms:**
- Backup items show "Unknown (ID: 0)" for scope tags
- Logs contain: `Application must have one of the following scopes: DeviceManagementRBAC.Read.All`
**Solution:**
1. Add `DeviceManagementRBAC.Read.All` permission (see above)
2. **Grant admin consent** (critical step!)
3. Wait 5-10 minutes for Azure to propagate permissions
4. Clear cache: `php artisan cache:clear`
5. Test again
### Error: "Insufficient privileges to complete the operation"
**Cause:** The user account used to grant admin consent doesn't have sufficient permissions.
**Solution:**
- Use an account with **Global Administrator** or **Privileged Role Administrator** role
- Or have the IT admin grant consent for the organization
### Permissions showing but still getting 403
**Possible causes:**
1. Admin consent not granted (click the button!)
2. Permissions not yet propagated (wait 5-10 minutes)
3. Wrong tenant (check tenant ID in app config)
4. Cached token needs refresh (clear cache + restart)
## Feature Impact Matrix
| Feature | Required Permissions | Without Permission | Impact Level |
|---------|---------------------|-------------------|--------------|
| Basic Policy Backup | `DeviceManagementConfiguration.Read.All` | Cannot backup | 🔴 Critical |
| Policy Restore | `DeviceManagementConfiguration.ReadWrite.All` | Cannot restore | 🔴 Critical |
| Scope Tag Names (004) | `DeviceManagementRBAC.Read.All` | Shows "Unknown (ID: X)" | 🟡 Medium |
| Assignment Names (004) | `Group.Read.All` + `Directory.Read.All` | Shows group IDs only | 🟡 Medium |
| Group Mapping (004) | `Group.Read.All` | Manual ID mapping required | 🟡 Medium |
## Security Notes
- All permissions are **Application Permissions** (app-level, not user-level)
- Requires **admin consent** from Global Administrator
- Use **least privilege principle**: Only add permissions for features you use
- Consider creating separate app registrations for different environments (dev/staging/prod)
- Rotate client secrets regularly (recommended: every 6 months)
## References
- [Microsoft Graph API Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference)
- [Intune Graph API Overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview)
- [App Registration Best Practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration)

View File

@ -48,11 +48,18 @@
@if (empty($canaries)) @if (empty($canaries))
<div class="text-sm text-gray-700">No canary results recorded.</div> <div class="text-sm text-gray-700">No canary results recorded.</div>
@else @else
<ul class="space-y-1 text-sm text-gray-800"> <ul class="space-y-1 text-sm">
@foreach ($canaries as $key => $status) @foreach ($canaries as $key => $status)
<li> <li class="flex items-center gap-2">
<span class="font-semibold">{{ $key }}:</span> <span class="font-semibold text-gray-800">{{ $key }}:</span>
<span class="{{ $status === 'ok' ? 'text-green-700' : 'text-amber-700' }}">{{ $status }}</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{{ $status === 'ok' ? 'bg-green-100 text-green-700' : '' }}
{{ $status === 'error' ? 'bg-red-100 text-red-700' : '' }}
{{ $status === 'pending' ? 'bg-yellow-100 text-yellow-700' : '' }}
{{ !in_array($status, ['ok', 'error', 'pending']) ? 'bg-gray-100 text-gray-700' : '' }}
">
{{ $status }}
</span>
</li> </li>
@endforeach @endforeach
</ul> </ul>

View File

@ -42,12 +42,90 @@
</span> </span>
</div> </div>
@if (! empty($item['reason'])) @php
$itemReason = $item['reason'] ?? null;
$itemGraphMessage = $item['graph_error_message'] ?? null;
@endphp
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
<div class="mt-2 text-sm text-gray-800"> <div class="mt-2 text-sm text-gray-800">
{{ $item['reason'] }} {{ $itemReason }}
</div> </div>
@endif @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>
</details>
@endif
@endif
@if (! empty($item['created_policy_id'])) @if (! empty($item['created_policy_id']))
@php @php
$createdMode = $item['created_policy_mode'] ?? null; $createdMode = $item['created_policy_mode'] ?? null;

View File

@ -0,0 +1 @@
@livewire('policy-version-assignments-widget', ['version' => $record])

View File

@ -0,0 +1,138 @@
<div class="fi-section">
@if($version->assignments && count($version->assignments) > 0)
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }}
</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
<!-- Summary -->
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ count($version->assignments) }} assignment(s)
@php
$hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false;
@endphp
@if($hasOrphaned)
<span class="text-warning-600 dark:text-warning-400">(includes orphaned groups)</span>
@endif
</p>
</div>
<!-- Scope Tags -->
@php
$scopeTags = $version->scope_tags['names'] ?? [];
@endphp
@if(!empty($scopeTags))
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
<div class="mt-2 flex flex-wrap gap-2">
@foreach($scopeTags as $tag)
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
{{ $tag }}
</span>
@endforeach
</div>
</div>
@endif
<!-- Assignment Details -->
<div>
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
<div class="mt-2 space-y-2">
@foreach($version->assignments as $assignment)
@php
$target = $assignment['target'] ?? [];
$type = $target['@odata.type'] ?? '';
$typeKey = strtolower((string) $type);
$intent = $assignment['intent'] ?? 'apply';
$typeName = match (true) {
str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
default => 'Unknown',
};
$groupId = $target['groupId'] ?? null;
$groupName = $target['group_display_name'] ?? null;
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
$filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
$filterName = $target['assignment_filter_name'] ?? null;
$filterLabel = $filterName ?? $filterId;
@endphp
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
@if($groupId)
<span class="text-gray-600 dark:text-gray-400">:</span>
@if($groupOrphaned)
<span class="text-warning-600 dark:text-warning-400">
⚠️ Unknown group (ID: {{ $groupId }})
</span>
@elseif($groupName)
<span class="text-gray-700 dark:text-gray-300">
{{ $groupName }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-500">
({{ $groupId }})
</span>
@else
<span class="text-gray-700 dark:text-gray-300">
Group ID: {{ $groupId }}
</span>
@endif
@endif
@if($filterLabel)
<span class="text-xs text-gray-500 dark:text-gray-500">
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
</span>
@endif
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
</div>
@endforeach
</div>
</div>
</div>
</div>
@else
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')
->where('created_at', '<=', $version->captured_at)
->exists();
@endphp
@if($hasBackupItem)
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
💡 Assignment data may be available in related backup items.
</p>
@endif
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,653 @@
# Feature 004: Assignments & Scope Tags - Data Model
## Overview
This document defines the database schema changes, model relationships, and data structures for the Assignments & Scope Tags feature.
---
## Database Schema Changes
### Migration 1: Add `assignments` column to `backup_items`
**File**: `database/migrations/xxxx_add_assignments_to_backup_items.php`
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata');
});
}
public function down(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->dropColumn('assignments');
});
}
};
```
**JSONB Structure** (`backup_items.assignments`):
```json
[
{
"id": "abc-123-def",
"target": {
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
"groupId": "group-abc-123",
"deviceAndAppManagementAssignmentFilterId": null,
"deviceAndAppManagementAssignmentFilterType": "none"
},
"intent": "apply",
"settings": null,
"source": "direct"
},
{
"id": "def-456-ghi",
"target": {
"@odata.type": "#microsoft.graph.exclusionGroupAssignmentTarget",
"groupId": "group-def-456"
},
"intent": "exclude"
}
]
```
**Index** (optional, for analytics queries):
```php
// Add GIN index for JSONB queries
DB::statement('CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments)');
```
---
### Migration 2: Extend `backup_items.metadata` JSONB
**Purpose**: Store assignment summary in metadata for quick access
**Updated Schema** (`backup_items.metadata`):
```json
{
// Existing fields
"policy_name": "Windows Security Baseline",
"policy_type": "settingsCatalogPolicy",
"tenant_name": "Contoso Corp",
// NEW: Assignment metadata
"assignment_count": 5,
"scope_tag_ids": ["0", "abc-123", "def-456"],
"scope_tag_names": ["Default", "HR-Admins", "Finance-Admins"],
"has_orphaned_assignments": false,
"assignments_fetch_failed": false
}
```
**No Migration Needed**: `metadata` column already exists as JSONB, just update application code to populate these fields.
---
### Migration 3: Add `group_mapping` column to `restore_runs`
**File**: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php`
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->json('group_mapping')->nullable()->after('results');
});
}
public function down(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->dropColumn('group_mapping');
});
}
};
```
**JSONB Structure** (`restore_runs.group_mapping`):
```json
{
"source-group-abc-123": "target-group-xyz-789",
"source-group-def-456": "target-group-uvw-012",
"source-group-ghi-789": "SKIP"
}
```
**Usage**: Maps source tenant group IDs to target tenant group IDs during restore. Special value `"SKIP"` means do not restore assignments targeting that group.
---
## Model Changes
### BackupItem Model
**File**: `app/Models/BackupItem.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BackupItem extends Model
{
protected $fillable = [
'backup_set_id',
'resource_type',
'resource_id',
'payload',
'metadata',
'assignments', // NEW
];
protected $casts = [
'payload' => 'array',
'metadata' => 'array',
'assignments' => 'array', // NEW
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// Relationships
public function backupSet()
{
return $this->belongsTo(BackupSet::class);
}
// NEW: Assignment helpers
public function getAssignmentCountAttribute(): int
{
return count($this->assignments ?? []);
}
public function hasAssignments(): bool
{
return !empty($this->assignments);
}
public function getGroupIdsAttribute(): array
{
return collect($this->assignments ?? [])
->pluck('target.groupId')
->filter()
->unique()
->values()
->toArray();
}
public function getScopeTagIdsAttribute(): array
{
return $this->metadata['scope_tag_ids'] ?? ['0'];
}
public function getScopeTagNamesAttribute(): array
{
return $this->metadata['scope_tag_names'] ?? ['Default'];
}
public function hasOrphanedAssignments(): bool
{
return $this->metadata['has_orphaned_assignments'] ?? false;
}
public function assignmentsFetchFailed(): bool
{
return $this->metadata['assignments_fetch_failed'] ?? false;
}
// NEW: Scope for filtering policies with assignments
public function scopeWithAssignments($query)
{
return $query->whereNotNull('assignments')
->whereRaw('json_array_length(assignments) > 0');
}
}
```
---
### RestoreRun Model
**File**: `app/Models/RestoreRun.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RestoreRun extends Model
{
protected $fillable = [
'backup_set_id',
'target_tenant_id',
'status',
'results',
'group_mapping', // NEW
'started_at',
'completed_at',
];
protected $casts = [
'results' => 'array',
'group_mapping' => 'array', // NEW
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
// Relationships
public function backupSet()
{
return $this->belongsTo(BackupSet::class);
}
public function targetTenant()
{
return $this->belongsTo(Tenant::class, 'target_tenant_id');
}
// NEW: Group mapping helpers
public function hasGroupMapping(): bool
{
return !empty($this->group_mapping);
}
public function getMappedGroupId(string $sourceGroupId): ?string
{
return $this->group_mapping[$sourceGroupId] ?? null;
}
public function isGroupSkipped(string $sourceGroupId): bool
{
return $this->group_mapping[$sourceGroupId] === 'SKIP';
}
public function getUnmappedGroupIds(array $sourceGroupIds): array
{
return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? []));
}
public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void
{
$mapping = $this->group_mapping ?? [];
$mapping[$sourceGroupId] = $targetGroupId;
$this->group_mapping = $mapping;
}
// NEW: Assignment restore outcomes
public function getAssignmentRestoreOutcomes(): array
{
return $this->results['assignment_outcomes'] ?? [];
}
public function getSuccessfulAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn($outcome) => $outcome['status'] === 'success'
));
}
public function getFailedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn($outcome) => $outcome['status'] === 'failed'
));
}
public function getSkippedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
fn($outcome) => $outcome['status'] === 'skipped'
));
}
}
```
---
### Policy Model (Extensions)
**File**: `app/Models/Policy.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Policy extends Model
{
// Existing code...
// NEW: Assignment relationship (virtual, data comes from Graph API)
public function getAssignmentsAttribute(): ?array
{
// This is fetched on-demand from Graph API, not stored in DB
// Cached for 5 minutes to reduce API calls
return Cache::remember("policy_assignments:{$this->id}", 300, function () {
return app(AssignmentFetcher::class)->fetch($this->tenant_id, $this->graph_id);
});
}
public function hasAssignments(): bool
{
return !empty($this->assignments);
}
// NEW: Scope for policies that support assignments
public function scopeSupportsAssignments($query)
{
// Only Settings Catalog policies support assignments in Phase 1
return $query->where('type', 'settingsCatalogPolicy');
}
}
```
---
## Service Layer Data Structures
### AssignmentFetcher Service
**Output Structure**:
```php
[
[
'id' => 'abc-123-def',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-abc-123',
'deviceAndAppManagementAssignmentFilterId' => null,
'deviceAndAppManagementAssignmentFilterType' => 'none',
],
'intent' => 'apply',
'settings' => null,
'source' => 'direct',
],
// ... more assignments
]
```
---
### GroupResolver Service
**Input**: Array of group IDs
**Output**:
```php
[
'group-abc-123' => [
'id' => 'group-abc-123',
'displayName' => 'All Users',
'orphaned' => false,
],
'group-def-456' => [
'id' => 'group-def-456',
'displayName' => null,
'orphaned' => true, // Group doesn't exist in tenant
],
]
```
---
### ScopeTagResolver Service
**Input**: Array of scope tag IDs
**Output**:
```php
[
[
'id' => '0',
'displayName' => 'Default',
],
[
'id' => 'abc-123-def',
'displayName' => 'HR-Admins',
],
]
```
---
### AssignmentRestoreService
**Input**: Policy ID, assignments array, group mapping
**Output**:
```php
[
[
'status' => 'success',
'assignment' => [...],
'assignment_id' => 'new-abc-123',
],
[
'status' => 'failed',
'assignment' => [...],
'error' => 'Group not found: xyz-789',
'request_id' => 'abc-def-ghi',
],
[
'status' => 'skipped',
'assignment' => [...],
],
]
```
---
## Audit Log Entries
### New Action Types
**File**: `config/audit_log_actions.php` (if exists, or add to model)
```php
return [
// Existing actions...
// NEW: Assignment backup/restore actions
'backup.assignments.included' => 'Backup created with assignments',
'backup.assignments.fetch_failed' => 'Failed to fetch assignments during backup',
'restore.group_mapping.applied' => 'Group mapping applied during restore',
'restore.assignment.created' => 'Assignment created during restore',
'restore.assignment.failed' => 'Assignment failed to restore',
'restore.assignment.skipped' => 'Assignment skipped (group mapping)',
'policy.assignments.viewed' => 'Policy assignments viewed',
];
```
### Example Audit Log Entries
```php
// Backup with assignments
AuditLog::create([
'user_id' => auth()->id(),
'tenant_id' => $tenant->id,
'action' => 'backup.assignments.included',
'resource_type' => 'backup_set',
'resource_id' => $backupSet->id,
'metadata' => [
'policy_count' => 15,
'assignment_count' => 47,
],
]);
// Group mapping applied
AuditLog::create([
'user_id' => auth()->id(),
'tenant_id' => $targetTenant->id,
'action' => 'restore.group_mapping.applied',
'resource_type' => 'restore_run',
'resource_id' => $restoreRun->id,
'metadata' => [
'source_tenant_id' => $sourceTenant->id,
'group_mapping' => $restoreRun->group_mapping,
'mapped_count' => 5,
'skipped_count' => 2,
],
]);
// Assignment created
AuditLog::create([
'user_id' => auth()->id(),
'tenant_id' => $targetTenant->id,
'action' => 'restore.assignment.created',
'resource_type' => 'assignment',
'resource_id' => $assignmentId,
'metadata' => [
'policy_id' => $policyId,
'target_group_id' => $targetGroupId,
'intent' => 'apply',
],
]);
```
---
## PostgreSQL Indexes
### Recommended Indexes
```sql
-- GIN index for JSONB assignment queries (optional, for analytics)
CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments);
-- Index for filtering policies with assignments
CREATE INDEX backup_items_assignments_not_null
ON backup_items (id)
WHERE assignments IS NOT NULL;
-- Index for restore runs with group mapping
CREATE INDEX restore_runs_group_mapping_not_null
ON restore_runs (id)
WHERE group_mapping IS NOT NULL;
-- Composite index for tenant + resource type queries
CREATE INDEX backup_items_tenant_type_idx
ON backup_items (tenant_id, resource_type, created_at DESC);
```
---
## Data Size Estimates
### Storage Impact
| Entity | Existing Size | Assignment Data | Total Size | Growth |
|--------|--------------|----------------|-----------|--------|
| `backup_items` (1 policy) | ~10-50 KB | ~2-5 KB | ~12-55 KB | +20-40% |
| `backup_items` (100 policies) | ~1-5 MB | ~200-500 KB | ~1.2-5.5 MB | +20-40% |
| `restore_runs` (with mapping) | ~5-10 KB | ~1-2 KB | ~6-12 KB | +20% |
**Rationale**: Assignments are relatively small JSON objects. Even policies with 20+ assignments stay under 10 KB for assignment data.
---
## Migration Rollback Strategy
### If Rollback Needed
```php
// Rollback Migration 1
php artisan migrate:rollback --step=1
// Drops `backup_items.assignments` column
// Rollback Migration 2
php artisan migrate:rollback --step=1
// Drops `restore_runs.group_mapping` column
```
**Data Loss**: Rolling back will lose all stored assignments and group mappings. Backups can be re-created with assignments after rolling forward again.
**Safe Rollback**: Since assignments are optional (controlled by checkbox), existing backups without assignments remain functional.
---
## Validation Rules
### BackupItem Validation
```php
// In BackupItem model or Form Request
public static function assignmentsValidationRules(): array
{
return [
'assignments' => ['nullable', 'array'],
'assignments.*.id' => ['required', 'string'],
'assignments.*.target' => ['required', 'array'],
'assignments.*.target.@odata.type' => ['required', 'string'],
'assignments.*.target.groupId' => ['required_if:assignments.*.target.@odata.type,#microsoft.graph.groupAssignmentTarget', 'string'],
'assignments.*.intent' => ['required', 'string', 'in:apply,exclude'],
];
}
```
### RestoreRun Group Mapping Validation
```php
// In RestoreRun model or Form Request
public static function groupMappingValidationRules(): array
{
return [
'group_mapping' => ['nullable', 'array'],
'group_mapping.*' => ['string'], // Target group ID or "SKIP"
];
}
```
---
## Summary
### Database Changes
- ✅ `backup_items.assignments` (JSONB, nullable)
- ✅ `backup_items.metadata` (extend with assignment summary)
- ✅ `restore_runs.group_mapping` (JSONB, nullable)
### Model Enhancements
- ✅ `BackupItem`: Assignment accessors, scopes, helpers
- ✅ `RestoreRun`: Group mapping helpers, outcome methods
- ✅ `Policy`: Virtual assignments relationship (cached)
### Indexes
- ✅ GIN index on `backup_items.assignments` (optional)
- ✅ Partial indexes for non-null checks
### Data Structures
- ✅ Assignment JSON schema defined
- ✅ Group mapping JSON schema defined
- ✅ Audit log action types defined
---
**Status**: Data Model Complete
**Next Document**: `quickstart.md`

View File

@ -0,0 +1,461 @@
# Feature 004: Assignments & Scope Tags - Implementation Plan
## Project Context
### Technical Foundation
- **Laravel**: 12 (latest stable)
- **PHP**: 8.4.15
- **Admin UI**: Filament v4
- **Interactive Components**: Livewire v3
- **Database**: PostgreSQL with JSONB
- **External API**: Microsoft Graph API (Intune endpoints)
- **Testing**: Pest v4 (unit, feature, browser tests)
- **Local Dev**: Laravel Sail (Docker)
- **Deployment**: Dokploy (VPS, staging + production)
### Constitution Check
✅ Spec reviewed: `specs/004-assignments-scope-tags/spec.md`
✅ Constitution followed: `.specify/constitution.md`
✅ SDD workflow: Feature branch → spec + code → PR to dev
✅ Multi-agent coordination: Session branch pattern recommended
### Project Structure
```
app/
├── Models/
│ ├── BackupItem.php # Add assignments column
│ ├── RestoreRun.php # Add group_mapping column
│ └── Policy.php # Add assignments relationship methods
├── Services/
│ ├── Graph/
│ │ ├── AssignmentFetcher.php # NEW: Fetch assignments with fallback
│ │ ├── GroupResolver.php # NEW: Resolve group IDs to names
│ │ └── ScopeTagResolver.php # NEW: Resolve scope tag IDs with cache
│ ├── AssignmentBackupService.php # NEW: Backup assignments logic
│ └── AssignmentRestoreService.php # NEW: Restore assignments logic
├── Filament/
│ ├── Resources/
│ │ └── PolicyResource/
│ │ └── Pages/
│ │ └── ViewPolicy.php # Add Assignments tab
│ └── Forms/Components/
│ └── GroupMappingWizard.php # NEW: Multi-step group mapping
└── Jobs/
├── FetchAssignmentsJob.php # NEW: Async assignment fetch
└── RestoreAssignmentsJob.php # NEW: Async assignment restore
database/migrations/
├── xxxx_add_assignments_to_backup_items.php
└── xxxx_add_group_mapping_to_restore_runs.php
tests/
├── Unit/
│ ├── AssignmentFetcherTest.php
│ ├── GroupResolverTest.php
│ └── ScopeTagResolverTest.php
├── Feature/
│ ├── BackupWithAssignmentsTest.php
│ ├── PolicyViewAssignmentsTabTest.php
│ ├── RestoreGroupMappingTest.php
│ └── RestoreAssignmentApplicationTest.php
└── Browser/
└── GroupMappingWizardTest.php
```
---
## Implementation Phases
### Phase 1: Setup & Database (Foundation)
**Duration**: 2-3 hours
**Goal**: Prepare data layer for storing assignments and group mappings
**Tasks**:
1. Create migration for `backup_items.assignments` JSONB column
2. Create migration for `restore_runs.group_mapping` JSONB column
3. Update `BackupItem` model with `assignments` cast and accessor methods
4. Update `RestoreRun` model with `group_mapping` cast and helper methods
5. Add Graph contract config for assignments endpoints in `config/graph_contracts.php`
6. Create unit tests for model methods
**Acceptance Criteria**:
- Migrations reversible and run cleanly on Sail
- Models have proper JSONB casts
- Unit tests pass for assignment/mapping accessors
---
### Phase 2: Graph API Integration (Core Services)
**Duration**: 4-6 hours
**Goal**: Build reliable services to fetch/resolve assignments and scope tags
**Tasks**:
1. Create `AssignmentFetcher` service with fallback strategy:
- Primary: GET `/assignments`
- Fallback: GET with `$expand=assignments`
- Error handling with fail-soft
2. Create `GroupResolver` service:
- POST `/directoryObjects/getByIds` batch resolution
- Handle orphaned IDs gracefully
- Cache resolved groups (5 min TTL)
3. Create `ScopeTagResolver` service:
- GET `/deviceManagement/roleScopeTags`
- Cache scope tags (1 hour TTL)
- Extract from policy payload's `roleScopeTagIds`
4. Write unit tests mocking Graph responses:
- Success scenarios
- Partial failures (some IDs not found)
- Complete failures (API down)
**Acceptance Criteria**:
- Services handle all failure scenarios gracefully
- Tests achieve 90%+ coverage
- Fallback strategies proven with mocks
- Cache TTLs configurable via config
---
### Phase 3: US1 - Backup with Assignments (MVP Core)
**Duration**: 4-5 hours
**Goal**: Allow admins to optionally include assignments in backups
**Tasks**:
1. Add "Include Assignments & Scope Tags" checkbox to Backup creation form
2. Create `AssignmentBackupService`:
- Accept tenantId, policyId, includeAssignments flag
- Call `AssignmentFetcher` if flag enabled
- Resolve scope tag names via `ScopeTagResolver`
- Update `backup_items.metadata` with assignment count
- Store assignments in `backup_items.assignments` column
3. Dispatch async job `FetchAssignmentsJob` if checkbox enabled
4. Handle job failures: log warning, set `assignments_fetch_failed: true`
5. Create feature test: `BackupWithAssignmentsTest`
- Test backup with checkbox enabled
- Test backup with checkbox disabled
- Test assignment fetch failure handling
6. Add audit log entry: `backup.assignments.included`
**Acceptance Criteria**:
- Checkbox appears on Settings Catalog backup forms only
- Assignments stored in JSONB with correct schema
- Metadata includes `assignment_count`, `scope_tag_ids`, `has_orphaned_assignments`
- Feature test passes with mocked Graph responses
- Audit log records decision
---
### Phase 4: US2 - Policy View with Assignments Tab
**Duration**: 3-4 hours
**Goal**: Display assignments in read-only view for auditing
**Tasks**:
1. Add "Assignments" tab to `PolicyResource/ViewPolicy.php`
2. Create Filament Table for assignments:
- Columns: Type, Name, Mode (Include/Exclude), ID
- Handle orphaned IDs: "Unknown Group (ID: {id})" with warning icon
3. Create Scope Tags section (list with IDs)
4. Handle empty state: "No assignments found"
5. Update `BackupItem` detail view to show assignment summary in metadata card
6. Create feature test: `PolicyViewAssignmentsTabTest`
- Test assignments table rendering
- Test orphaned ID display
- Test scope tags section
- Test empty state
**Acceptance Criteria**:
- Tab visible only for Settings Catalog policies with assignments
- Orphaned IDs render with clear warning
- Scope tags display with names + IDs
- Feature test passes
---
### Phase 5: US3 - Restore with Group Mapping (Core Restore)
**Duration**: 6-8 hours
**Goal**: Enable cross-tenant restores with group mapping wizard
**Tasks**:
1. Create `GroupMappingWizard` Filament multi-step form component:
- Step 1: Restore Preview (existing)
- Step 2: Group Mapping (NEW)
- Step 3: Confirm (existing)
2. Implement Group Mapping step logic:
- Detect unresolved groups via POST `/directoryObjects/getByIds`
- Fetch target tenant groups (with caching, 5 min TTL)
- Render table: Source Group | Target Group Dropdown | Skip Checkbox
- Search/filter support for 500+ groups
3. Persist group mapping in `restore_runs.group_mapping` JSON
4. Create `AssignmentRestoreService`:
- Accept policyId, assignments array, group_mapping object
- Replace source group IDs with mapped target IDs
- Skip assignments marked "Skip"
- Execute DELETE-then-CREATE pattern:
* GET existing assignments
* DELETE each (204 No Content expected)
* POST each new/mapped assignment (201 Created expected)
- Handle per-assignment failures (fail-soft)
- Log outcomes per assignment
5. Dispatch async job `RestoreAssignmentsJob`
6. Create feature test: `RestoreGroupMappingTest`
- Test group mapping wizard flow
- Test group ID resolution
- Test mapping persistence
- Test skip functionality
7. Add audit log entries:
- `restore.group_mapping.applied`
- `restore.assignment.created` (per assignment)
- `restore.assignment.skipped`
**Acceptance Criteria**:
- Group mapping step appears only when unresolved groups exist
- Dropdown searchable with 500+ groups
- Mapping persisted and visible in audit logs
- Restore applies assignments correctly with mapped IDs
- Per-assignment outcomes logged
- Feature test passes
---
### Phase 6: US4 - Restore Preview with Assignment Diff
**Duration**: 2-3 hours
**Goal**: Show admins what assignments will change before restore
**Tasks**:
1. Enhance Restore Preview step to show assignment diff:
- Added assignments (green)
- Removed assignments (red)
- Unchanged assignments (gray)
2. Add scope tag diff: "Scope Tags: 2 matched, 1 not found in target"
3. Create diff algorithm:
- Compare source assignments with target policy's current assignments
- Group by change type (added/removed/unchanged)
4. Update feature test: `RestoreAssignmentApplicationTest` to verify diff display
**Acceptance Criteria**:
- Diff shows clear visual indicators (colors, icons)
- Scope tag warnings visible
- Diff accurate for all scenarios (same tenant, cross-tenant, empty target)
---
### Phase 7: Scope Tags (Full Support)
**Duration**: 2-3 hours
**Goal**: Complete scope tag handling in backup/restore
**Tasks**:
1. Update `AssignmentBackupService` to extract `roleScopeTagIds` from policy payload
2. Resolve scope tag names via `ScopeTagResolver` during backup
3. Update restore logic to preserve scope tag IDs if they exist in target
4. Log warnings for missing scope tags (don't block restore)
5. Update unit test: `ScopeTagResolverTest`
6. Update feature test: Add scope tag scenarios to `RestoreAssignmentApplicationTest`
**Acceptance Criteria**:
- Scope tags captured during backup with names
- Restore preserves IDs when available in target
- Warnings logged for missing scope tags
- Tests pass with various scope tag scenarios
---
### Phase 8: Polish & Performance
**Duration**: 3-4 hours
**Goal**: Optimize performance and improve UX
**Tasks**:
1. Add loading indicators to Group Mapping dropdown (wire:loading)
2. Implement group search debouncing (500ms)
3. Optimize Graph API calls:
- Batch group resolution (max 100 IDs per batch)
- Add 100ms delay between sequential assignment POST calls
4. Add cache warming for target tenant groups
5. Create performance test: Restore 50 policies with 10 assignments each
6. Add tooltips/help text:
- Backup checkbox: "Captures group/user targeting and RBAC scope. Adds ~2-5 KB per policy."
- Group Mapping: "Map source groups to target groups for cross-tenant migrations."
7. Update documentation: Add "Assignments & Scope Tags" section to README
**Acceptance Criteria**:
- Loading states visible during async operations
- Search responsive (no lag with 500+ groups)
- Performance benchmarks documented
- Tooltips clear and helpful
- README updated
---
### Phase 9: Testing & QA
**Duration**: 2-3 hours
**Goal**: Comprehensive testing across all scenarios
**Tasks**:
1. Manual QA checklist:
- ✅ Create backup with assignments checkbox (same tenant)
- ✅ Create backup without assignments checkbox
- ✅ View policy with assignments tab
- ✅ Restore to same tenant (auto-match groups)
- ✅ Restore to different tenant (group mapping wizard)
- ✅ Handle orphaned group IDs gracefully
- ✅ Skip assignments during group mapping
- ✅ Handle Graph API failures (assignments fetch, group resolution)
2. Browser test: `GroupMappingWizardTest`
- Navigate through multi-step wizard
- Search groups in dropdown
- Toggle skip checkboxes
- Verify mapping persistence
3. Load testing: 100+ policies with 20 assignments each
4. Staging deployment validation
**Acceptance Criteria**:
- All manual QA scenarios pass
- Browser test passes on Chrome/Firefox
- Load test completes under 5 minutes
- Staging environment stable
---
### Phase 10: Deployment & Documentation
**Duration**: 1-2 hours
**Goal**: Production-ready deployment
**Tasks**:
1. Create deployment checklist:
- Run migrations on staging
- Verify no data loss
- Test on production-like data volume
2. Update `.specify/spec.md` with implementation notes
3. Create migration guide for existing backups (no retroactive assignment capture)
4. Add monitoring alerts:
- Assignment fetch failure rate > 10%
- Group resolution failure rate > 5%
5. Production deployment:
- Deploy to production via Dokploy
- Monitor logs for 24 hours
- Verify no Graph API rate limit issues
**Acceptance Criteria**:
- Production deployment successful
- No critical errors in 24-hour monitoring window
- Documentation complete and accurate
---
## Dependencies
### Hard Dependencies (Required)
- Feature 001: Backup/Restore core infrastructure ✅
- Graph Contract Registry ✅
- Filament multi-step forms (built-in) ✅
### Soft Dependencies (Nice to Have)
- Feature 005: Bulk Operations (for bulk assignment backup) 🚧
---
## Risk Management
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|-----------|
| Graph API assignments endpoint slow | Medium | Medium | Async fetch with fail-soft, cache groups |
| Target tenant has 1000+ groups | High | Medium | Searchable dropdown with pagination, cache |
| Group IDs change across tenants | High | High | Group name-based fallback matching (future) |
| Scope Tag IDs don't exist in target | Medium | Low | Log warning, allow policy creation |
| Assignment restore fails mid-process | Medium | High | Per-assignment error handling, audit log |
---
## Success Metrics
### Functional
- ✅ Backup checkbox functional, assignments captured
- ✅ Policy View shows assignments tab with accurate data
- ✅ Group Mapping wizard handles 100+ groups smoothly
- ✅ Restore applies assignments with 90%+ success rate
- ✅ Audit logs record all mapping decisions
### Technical
- ✅ Tests achieve 85%+ coverage for new code
- ✅ Graph API calls < 2 seconds average
- ✅ Group mapping UI responsive with 500+ groups
- ✅ Assignment restore completes in < 30 seconds for 20 policies
### User Experience
- ✅ Admins can backup/restore assignments without manual intervention
- ✅ Cross-tenant migrations supported with clear group mapping UX
- ✅ Orphaned IDs handled gracefully with clear warnings
---
## MVP Scope (Phases 1-4 + Core of Phase 5)
**Estimated Duration**: 16-22 hours
**Included**:
- US1: Backup with Assignments checkbox ✅
- US2: Policy View with Assignments tab ✅
- US3: Restore with Group Mapping (basic, same-tenant auto-match) ✅
**Excluded** (Post-MVP):
- US4: Restore Preview with detailed diff
- Scope Tags full support
- Performance optimizations
- Cross-tenant group mapping wizard
**Rationale**: MVP proves core value (backup/restore assignments) while deferring complex cross-tenant mapping for iteration.
---
## Full Implementation Estimate
**Total Duration**: 30-40 hours
**Phase Breakdown**:
- Phase 1: Setup & Database (2-3 hours)
- Phase 2: Graph API Integration (4-6 hours)
- Phase 3: US1 - Backup (4-5 hours)
- Phase 4: US2 - Policy View (3-4 hours)
- Phase 5: US3 - Group Mapping (6-8 hours)
- Phase 6: US4 - Restore Preview (2-3 hours)
- Phase 7: Scope Tags (2-3 hours)
- Phase 8: Polish (3-4 hours)
- Phase 9: Testing & QA (2-3 hours)
- Phase 10: Deployment (1-2 hours)
**Parallel Opportunities**:
- Phase 2 (Graph services) + Phase 3 (Backup) can be split across developers
- Phase 4 (Policy View) independent of Phase 5 (Restore)
- Phase 7 (Scope Tags) can be developed alongside Phase 6 (Restore Preview)
---
## Open Questions (For Review)
1. **Smart Matching**: Should we support "smart matching" (group name similarity) for group mapping?
- **Recommendation**: Phase 2 feature, start with manual mapping MVP
2. **Dynamic Groups**: How to handle dynamic groups (membership rules) - copy rules or skip?
- **Recommendation**: Skip initially, document as limitation, consider for future
3. **Scope Tag Blocking**: Should Scope Tag warnings block restore or just warn?
- **Recommendation**: Warn only, allow restore to proceed (Graph API default behavior)
4. **Assignment Filters**: Should we preserve assignment filters (device/user filters)?
- **Recommendation**: Yes, preserve all filter properties in JSON
---
## Next Steps
1. **Review**: Team review of plan.md, resolve open questions
2. **Research**: Create `research.md` with detailed technology decisions
3. **Data Model**: Create `data-model.md` with schema details
4. **Quickstart**: Create `quickstart.md` with developer setup
5. **Tasks**: Break down phases into granular tasks in `tasks.md`
6. **Implement**: Start with Phase 1 (Setup & Database)
---
**Status**: Ready for Review
**Created**: 2025-12-22
**Estimated Total Duration**: 30-40 hours (MVP: 16-22 hours)
**Next Document**: `research.md`

View File

@ -0,0 +1,616 @@
# Feature 004: Assignments & Scope Tags - Developer Quickstart
## Overview
This guide helps developers quickly set up their environment and start working on the Assignments & Scope Tags feature.
---
## Prerequisites
- **Laravel Sail** installed and running
- **PostgreSQL** database via Sail
- **Microsoft Graph API** test tenant credentials
- **Redis** (optional, for caching - Sail includes it)
---
## Quick Setup
### 1. Start Sail Environment
```bash
# Start all containers
./vendor/bin/sail up -d
# Check status
./vendor/bin/sail ps
```
### 2. Run Migrations
```bash
# Run new migrations for assignments
./vendor/bin/sail artisan migrate
# Verify new columns exist
./vendor/bin/sail artisan tinker
>>> Schema::hasColumn('backup_items', 'assignments')
=> true
>>> Schema::hasColumn('restore_runs', 'group_mapping')
=> true
```
### 3. Seed Test Data (Optional)
```bash
# Seed tenants, policies, and backup sets
./vendor/bin/sail artisan db:seed
# Or create specific test data
./vendor/bin/sail artisan tinker
>>> $tenant = Tenant::factory()->create(['name' => 'Test Tenant']);
>>> $policy = Policy::factory()->for($tenant)->create(['type' => 'settingsCatalogPolicy']);
>>> $backupSet = BackupSet::factory()->for($tenant)->create();
```
### 4. Configure Graph API Credentials
```bash
# Copy example env
cp .env.example .env.testing
# Add Graph API credentials
GRAPH_CLIENT_ID=your-client-id
GRAPH_CLIENT_SECRET=your-client-secret
GRAPH_TENANT_ID=your-test-tenant-id
```
---
## Development Workflow
### Running Tests
```bash
# Run all tests
./vendor/bin/sail artisan test
# Run specific test file
./vendor/bin/sail artisan test tests/Feature/BackupWithAssignmentsTest.php
# Run tests with filter
./vendor/bin/sail artisan test --filter=assignment
# Run tests with coverage
./vendor/bin/sail artisan test --coverage
```
### Using Tinker for Debugging
```bash
./vendor/bin/sail artisan tinker
```
**Common Tinker Commands**:
```php
// Fetch assignments for a policy
$fetcher = app(AssignmentFetcher::class);
$assignments = $fetcher->fetch('tenant-id', 'policy-graph-id');
dump($assignments);
// Test group resolution
$resolver = app(GroupResolver::class);
$groups = $resolver->resolveGroupIds(['group-1', 'group-2'], 'tenant-id');
dump($groups);
// Test backup with assignments
$service = app(AssignmentBackupService::class);
$backupItem = $service->backup(
tenantId: 1,
policyId: 'abc-123',
includeAssignments: true
);
dump($backupItem->assignments);
// Test group mapping
$restoreRun = RestoreRun::first();
$restoreRun->addGroupMapping('source-group-1', 'target-group-1');
$restoreRun->save();
dump($restoreRun->group_mapping);
// Clear cache
Cache::flush();
```
---
## Manual Testing Scenarios
### Scenario 1: Backup with Assignments (Happy Path)
**Goal**: Verify assignments are captured during backup
**Steps**:
1. Navigate to Backup creation form: `/tenants/{tenant}/backups/create`
2. Select Settings Catalog policies
3. ✅ Check "Include Assignments & Scope Tags"
4. Click "Create Backup"
5. Wait for backup job to complete
**Verification**:
```bash
./vendor/bin/sail artisan tinker
>>> $backupItem = BackupItem::latest()->first();
>>> dump($backupItem->assignments);
>>> dump($backupItem->metadata['assignment_count']);
```
**Expected**:
- `assignments` column populated with JSON array
- `metadata['assignment_count']` matches actual count
- Audit log entry: `backup.assignments.included`
---
### Scenario 2: Policy View with Assignments Tab
**Goal**: Verify assignments display correctly in UI
**Steps**:
1. Navigate to Policy view: `/policies/{policy}`
2. Click "Assignments" tab
**Verification**:
- Table shows assignments (Type, Name, Mode, ID)
- Orphaned IDs render as "Unknown Group (ID: {id})" with warning icon
- Scope Tags section shows tag names + IDs
- Empty state if no assignments
**Edge Cases**:
- Policy with 0 assignments → Empty state
- Policy with orphaned group ID → Warning icon
- Policy without assignments metadata → Empty state
---
### Scenario 3: Restore with Group Mapping (Cross-Tenant)
**Goal**: Verify group mapping wizard works for cross-tenant restore
**Setup**:
```bash
# Create source and target tenants
./vendor/bin/sail artisan tinker
>>> $sourceTenant = Tenant::factory()->create(['name' => 'Source Tenant']);
>>> $targetTenant = Tenant::factory()->create(['name' => 'Target Tenant']);
# Create groups in both tenants (simulate via test data)
>>> Group::factory()->for($sourceTenant)->create(['graph_id' => 'source-group-1', 'display_name' => 'HR Team']);
>>> Group::factory()->for($targetTenant)->create(['graph_id' => 'target-group-1', 'display_name' => 'HR Department']);
```
**Steps**:
1. Navigate to Restore wizard: `/backups/{backup}/restore`
2. Select target tenant (different from source)
3. Click "Continue" to Restore Preview
4. **Group Mapping step should appear**:
- Source group: "HR Team (source-group-1)"
- Target group dropdown: searchable, populated with target tenant groups
- Select "HR Department" (target-group-1)
5. Click "Continue"
6. Review Restore Preview (should show mapped group)
7. Click "Restore"
**Verification**:
```bash
./vendor/bin/sail artisan tinker
>>> $restoreRun = RestoreRun::latest()->first();
>>> dump($restoreRun->group_mapping);
=> ["source-group-1" => "target-group-1"]
```
**Expected**:
- Group mapping step only appears when unresolved groups detected
- Dropdown searchable with 500+ groups
- Mapping persisted in `restore_runs.group_mapping`
- Audit log entries: `restore.group_mapping.applied`
---
### Scenario 4: Handle Graph API Failures
**Goal**: Verify fail-soft behavior when Graph API fails
**Mock Graph Failure**:
```php
// In tests or local dev with Http::fake()
Http::fake([
'*/assignments' => Http::response(null, 500), // Simulate failure
]);
```
**Steps**:
1. Create backup with "Include Assignments" checkbox
2. Observe behavior
**Verification**:
```bash
./vendor/bin/sail artisan tinker
>>> $backupItem = BackupItem::latest()->first();
>>> dump($backupItem->metadata['assignments_fetch_failed']);
=> true
>>> dump($backupItem->assignments);
=> null
```
**Expected**:
- Backup completes successfully (fail-soft)
- `assignments_fetch_failed` flag set to `true`
- Warning logged in `storage/logs/laravel.log`
- Audit log entry: `backup.assignments.fetch_failed`
---
### Scenario 5: Skip Assignments in Group Mapping
**Goal**: Verify "Skip" functionality in group mapping
**Steps**:
1. Follow Scenario 3 setup
2. In Group Mapping step:
- Check "Skip" checkbox for one source group
- Map other groups normally
3. Complete restore
**Verification**:
```bash
./vendor/bin/sail artisan tinker
>>> $restoreRun = RestoreRun::latest()->first();
>>> dump($restoreRun->group_mapping);
=> ["source-group-1" => "target-group-1", "source-group-2" => "SKIP"]
>>> dump($restoreRun->getSkippedAssignmentsCount());
=> 2 # Assignments targeting source-group-2 were skipped
```
**Expected**:
- Skipped group has `"SKIP"` value in mapping JSON
- Restore runs without creating assignments for skipped groups
- Audit log entries: `restore.assignment.skipped` (per skipped)
---
## Common Issues & Solutions
### Issue 1: Migrations Fail
**Error**: `SQLSTATE[42P01]: Undefined table: backup_items`
**Solution**:
```bash
# Reset database and re-run migrations
./vendor/bin/sail artisan migrate:fresh --seed
```
---
### Issue 2: Graph API Rate Limiting
**Error**: `429 Too Many Requests`
**Solution**:
```bash
# Add retry logic with exponential backoff (already in GraphClient)
# Or reduce test load:
# - Use Http::fake() in tests
# - Add delays between API calls (100ms)
```
**Check Rate Limit Headers**:
```php
// In GraphClient.php
Log::info('Graph API call', [
'endpoint' => $endpoint,
'retry_after' => $response->header('Retry-After'),
'remaining_calls' => $response->header('X-RateLimit-Remaining'),
]);
```
---
### Issue 3: Cache Not Clearing
**Error**: Stale group names in UI after updating tenant
**Solution**:
```bash
# Clear all cache
./vendor/bin/sail artisan cache:clear
# Clear specific cache keys
./vendor/bin/sail artisan tinker
>>> Cache::forget('groups:tenant-id:*');
>>> Cache::forget('scope_tags:all');
```
---
### Issue 4: Assignments Not Showing in UI
**Checklist**:
1. ✅ Checkbox was enabled during backup?
2. ✅ `backup_items.assignments` column has data? (check via tinker)
3. ✅ Policy type is `settingsCatalogPolicy`? (others not supported in Phase 1)
4. ✅ Graph API call succeeded? (check logs for errors)
**Debug**:
```bash
./vendor/bin/sail artisan tinker
>>> $backupItem = BackupItem::find(123);
>>> dump($backupItem->assignments);
>>> dump($backupItem->hasAssignments());
>>> dump($backupItem->metadata['assignments_fetch_failed']);
```
---
### Issue 5: Group Mapping Dropdown Slow
**Symptom**: Dropdown takes 5+ seconds to load with 500+ groups
**Solutions**:
1. **Increase cache TTL**:
```php
// In GroupResolver.php
Cache::remember("groups:{$tenantId}", 600, function () { ... }); // 10 min
```
2. **Pre-warm cache**:
```php
// In RestoreWizard.php mount()
public function mount()
{
// Cache groups when wizard opens
app(GroupResolver::class)->getAllForTenant($this->targetTenantId);
}
```
3. **Add debouncing**:
```php
// In Filament Select component
->debounce(500) // Wait 500ms after typing
```
---
## Performance Benchmarks
### Target Metrics
| Operation | Target | Actual (Measure) |
|-----------|--------|------------------|
| Assignment fetch (per policy) | < 2s | ___ |
| Group resolution (100 groups) | < 1s | ___ |
| Group mapping UI search | < 500ms | ___ |
| Assignment restore (20 policies) | < 30s | ___ |
### Measuring Performance
```bash
# Enable query logging
./vendor/bin/sail artisan tinker
>>> DB::enableQueryLog();
# Run operation
>>> $fetcher = app(AssignmentFetcher::class);
>>> $start = microtime(true);
>>> $assignments = $fetcher->fetch('tenant-id', 'policy-id');
>>> $duration = microtime(true) - $start;
>>> dump("Duration: {$duration}s");
# Check queries
>>> dump(DB::getQueryLog());
```
### Load Testing
```bash
# Create 100 policies with assignments
./vendor/bin/sail artisan tinker
>>> for ($i = 0; $i < 100; $i++) {
>>> $policy = Policy::factory()->create(['type' => 'settingsCatalogPolicy']);
>>> $backupItem = BackupItem::factory()->for($policy->backupSet)->create([
>>> 'assignments' => [/* 10 assignments */]
>>> ]);
>>> }
# Measure restore time
>>> $start = microtime(true);
>>> $service = app(AssignmentRestoreService::class);
>>> $outcomes = $service->restoreBatch($policyIds, $groupMapping);
>>> $duration = microtime(true) - $start;
>>> dump("Restored 100 policies in {$duration}s");
```
---
## Debugging Tools
### 1. Laravel Telescope (Optional)
```bash
# Install Telescope (if not already)
./vendor/bin/sail composer require laravel/telescope --dev
./vendor/bin/sail artisan telescope:install
./vendor/bin/sail artisan migrate
# Access Telescope
# Navigate to: http://localhost/telescope
```
**Use Cases**:
- Monitor Graph API calls (Requests tab)
- Track slow queries (Queries tab)
- View cache hits/misses (Cache tab)
- Inspect jobs (Jobs tab)
---
### 2. Laravel Debugbar (Installed)
```bash
# Ensure Debugbar is enabled
DEBUGBAR_ENABLED=true # in .env
```
**Features**:
- Query count and duration per page load
- View all HTTP requests (including Graph API)
- Cache operations
- Timeline of execution
---
### 3. Graph API Explorer
**URL**: https://developer.microsoft.com/en-us/graph/graph-explorer
**Use Cases**:
- Test Graph API endpoints manually
- Verify assignment response structure
- Debug authentication issues
- Check available permissions
**Example Queries**:
```
GET /deviceManagement/configurationPolicies/{id}/assignments
POST /directoryObjects/getByIds
GET /deviceManagement/roleScopeTags
```
---
## Test Data Factories
### Factory: BackupItem with Assignments
```php
// database/factories/BackupItemFactory.php
public function withAssignments(int $count = 5): static
{
return $this->state(fn (array $attributes) => [
'assignments' => collect(range(1, $count))->map(fn ($i) => [
'id' => "assignment-{$i}",
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => "group-{$i}",
],
'intent' => 'apply',
])->toArray(),
'metadata' => array_merge($attributes['metadata'] ?? [], [
'assignment_count' => $count,
'scope_tag_ids' => ['0'],
'scope_tag_names' => ['Default'],
]),
]);
}
// Usage
$backupItem = BackupItem::factory()->withAssignments(10)->create();
```
---
### Factory: RestoreRun with Group Mapping
```php
// database/factories/RestoreRunFactory.php
public function withGroupMapping(array $mapping = []): static
{
return $this->state(fn (array $attributes) => [
'group_mapping' => $mapping ?: [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
]);
}
// Usage
$restoreRun = RestoreRun::factory()->withGroupMapping([
'source-abc' => 'target-xyz',
])->create();
```
---
## CI/CD Integration
### GitHub Actions / Gitea CI (Example)
```yaml
# .gitea/workflows/test.yml
name: Test Feature 004
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Sail
run: |
docker-compose up -d
docker-compose exec -T laravel.test composer install
- name: Run migrations
run: docker-compose exec -T laravel.test php artisan migrate --force
- name: Run tests
run: docker-compose exec -T laravel.test php artisan test --filter=Assignment
- name: Check code style
run: docker-compose exec -T laravel.test ./vendor/bin/pint --test
```
---
## Next Steps
1. **Read Planning Docs**:
- [plan.md](plan.md) - Implementation phases
- [research.md](research.md) - Technology decisions
- [data-model.md](data-model.md) - Database schema
2. **Start with Phase 1**:
- Run migrations
- Add model casts and accessors
- Write unit tests for model methods
3. **Build Graph Services** (Phase 2):
- Implement `AssignmentFetcher`
- Implement `GroupResolver`
- Implement `ScopeTagResolver`
- Write unit tests with mocked Graph responses
4. **Implement MVP** (Phase 3-4):
- Add backup checkbox
- Create assignment backup logic
- Add Policy View assignments tab
---
## Additional Resources
- **Laravel Docs**: https://laravel.com/docs/12.x
- **Filament Docs**: https://filamentphp.com/docs/4.x
- **Graph API Docs**: https://learn.microsoft.com/en-us/graph/api/overview
- **Pest Docs**: https://pestphp.com/docs/
---
**Status**: Quickstart Complete
**Next Document**: `tasks.md` (detailed task breakdown)

View File

@ -0,0 +1,566 @@
# Feature 004: Assignments & Scope Tags - Research Notes
## Overview
This document captures key technology decisions, API patterns, and implementation strategies for the Assignments & Scope Tags feature.
---
## Research Questions & Answers
### 1. How should we store assignments data?
**Question**: Should assignments be stored as JSONB in `backup_items` table or as separate relational tables?
**Options Evaluated**:
| Approach | Pros | Cons |
|----------|------|------|
| **JSONB Column** | Simple schema, flexible structure, fast writes, matches Graph API response format | Complex queries, no foreign key constraints, larger row size |
| **Separate Tables** (`assignment` table) | Normalized, relational integrity, easier reporting | Complex migrations, slower backups, graph-to-relational mapping overhead |
**Decision**: **JSONB Column** (`backup_items.assignments`)
**Rationale**:
1. Assignments are **immutable snapshots** - we're not querying/filtering them frequently
2. Graph API returns assignments as nested JSON - direct storage avoids mapping
3. Backup/restore operations are write-heavy, not read-heavy (JSONB excels here)
4. Simplifies restore logic (no need to reconstruct JSON from relations)
5. Matches existing pattern for policy payloads in `backup_items.payload` (JSONB)
**Implementation**:
```php
// Migration
Schema::table('backup_items', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata');
});
// Model Cast
protected $casts = [
'assignments' => 'array',
// ...
];
// Accessor for assignment count
public function getAssignmentCountAttribute(): int
{
return count($this->assignments ?? []);
}
```
**Trade-off**: If we need advanced assignment analytics (e.g., "show me all backups targeting group X"), we'll need to:
- Use PostgreSQL JSONB query operators (`@>`, `?`)
- Or add a read model (separate `assignment_analytics` table populated async)
---
### 2. What's the best Graph API fallback strategy for assignments?
**Question**: The spec mentions a fallback strategy for fetching assignments. What's the production-tested approach?
**Problem Context**:
- Graph API behavior varies by policy template family
- Some policies return empty `/assignments` endpoint but have assignments via `$expand`
- Known issue with certain Settings Catalog template types
**Strategy** (from spec FR-004.2):
```php
class AssignmentFetcher
{
public function fetch(string $tenantId, string $policyId): array
{
try {
// Primary: Direct assignments endpoint
$response = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
if (!empty($response['value'])) {
return $response['value'];
}
// Fallback: Use $expand (slower but more reliable)
$response = $this->graph->get(
"/deviceManagement/configurationPolicies",
[
'$filter' => "id eq '{$policyId}'",
'$expand' => 'assignments'
]
);
return $response['value'][0]['assignments'] ?? [];
} catch (GraphException $e) {
// Log warning, return empty (fail-soft)
Log::warning("Failed to fetch assignments for policy {$policyId}", [
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
'request_id' => $e->getRequestId(),
]);
return [];
}
}
}
```
**Testing Strategy**:
- Mock both successful and failed responses
- Test with empty `value` array (triggers fallback)
- Test complete failure (returns empty, logs warning)
**Edge Cases**:
- **Rate Limiting**: If primary call hits rate limit, fallback may also fail → log and continue
- **Timeout**: 30-second timeout on both calls → fail-soft, mark `assignments_fetch_failed: true`
---
### 3. How to resolve group IDs to names efficiently?
**Question**: We need to display group names in UI, but assignments only have group IDs. What's the best resolution strategy?
**Graph API Options**:
| Method | Endpoint | Pros | Cons |
|--------|----------|------|------|
| **Batch Resolution** | POST `/directoryObjects/getByIds` | Single request for 100+ IDs, stable API | Requires POST, batch size limit (1000) |
| **Filter Query** | GET `/groups?$filter=id in (...)` | Standard GET | Requires advanced query (not all tenants enabled), URL length limits |
| **Individual GET** | GET `/groups/{id}` per group | Simple | N+1 queries, slow for 50+ groups |
**Decision**: **POST `/directoryObjects/getByIds` with Caching**
**Rationale**:
1. Most reliable for large group counts (tested with 500+ groups)
2. Single request vs N requests
3. Works without advanced query requirements
4. Supports multiple object types (groups, users, devices)
**Implementation**:
```php
class GroupResolver
{
public function resolveGroupIds(array $groupIds, string $tenantId): array
{
// Check cache first (5 min TTL)
$cacheKey = "groups:{$tenantId}:" . md5(implode(',', $groupIds));
if ($cached = Cache::get($cacheKey)) {
return $cached;
}
// Batch resolve
$response = $this->graph->post('/directoryObjects/getByIds', [
'ids' => $groupIds,
'types' => ['group'],
]);
$resolved = collect($response['value'])->keyBy('id')->toArray();
// Handle orphaned IDs
$result = [];
foreach ($groupIds as $id) {
$result[$id] = $resolved[$id] ?? [
'id' => $id,
'displayName' => null, // Will render as "Unknown Group (ID: {id})"
'orphaned' => true,
];
}
Cache::put($cacheKey, $result, now()->addMinutes(5));
return $result;
}
}
```
**Optimization**: Pre-warm cache when entering Group Mapping wizard (fetch all target tenant groups once).
---
### 4. What's the UX pattern for Group Mapping with 500+ groups?
**Question**: How do we make group mapping usable for large tenants?
**UX Requirements** (from NFR-004.2):
- Must support 500+ groups
- Must be searchable
- Should feel responsive
**Filament Solution**: **Searchable Select with Server-Side Filtering**
```php
use Filament\Forms\Components\Select;
Select::make('target_group_id')
->label('Target Group')
->searchable()
->getSearchResultsUsing(fn (string $search) =>
Group::query()
->where('tenant_id', $this->targetTenantId)
->where('display_name', 'ilike', "%{$search}%")
->limit(50)
->pluck('display_name', 'graph_id')
)
->getOptionLabelUsing(fn ($value): ?string =>
Group::where('graph_id', $value)->first()?->display_name
)
->lazy() // Load options only when dropdown opens
->debounce(500) // Wait 500ms after typing before searching
```
**Caching Strategy**:
```php
// Pre-warm cache when wizard opens
public function mount()
{
// Cache all target tenant groups for 5 minutes
Cache::remember("groups:{$this->targetTenantId}", 300, function () {
return $this->graph->get('/groups?$select=id,displayName')['value'];
});
}
```
**Alternative** (if Filament Select is slow): Use Livewire component with Alpine.js dropdown + AJAX search.
---
### 5. How to handle assignment restore failures gracefully?
**Question**: What if some assignments succeed and others fail during restore?
**Requirements** (from FR-004.12, FR-004.13):
- Continue with remaining assignments (fail-soft)
- Log per-assignment outcome
- Report final status: "3 of 5 assignments restored"
**DELETE-then-CREATE Pattern**:
```php
class AssignmentRestoreService
{
public function restore(string $policyId, array $assignments, array $groupMapping): array
{
$outcomes = [];
// Step 1: DELETE existing assignments (clean slate)
$existing = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
foreach ($existing['value'] as $assignment) {
try {
$this->graph->delete("/deviceManagement/configurationPolicies/{$policyId}/assignments/{$assignment['id']}");
// 204 No Content = success
} catch (GraphException $e) {
Log::warning("Failed to delete assignment {$assignment['id']}", [
'error' => $e->getMessage(),
'request_id' => $e->getRequestId(),
]);
}
}
// Step 2: CREATE new assignments (with mapped IDs)
foreach ($assignments as $assignment) {
// Apply group mapping
if (isset($assignment['target']['groupId'])) {
$sourceGroupId = $assignment['target']['groupId'];
// Skip if marked in group mapping
if (isset($groupMapping[$sourceGroupId]) && $groupMapping[$sourceGroupId] === 'SKIP') {
$outcomes[] = ['status' => 'skipped', 'assignment' => $assignment];
continue;
}
// Replace with target group ID
$assignment['target']['groupId'] = $groupMapping[$sourceGroupId] ?? $sourceGroupId;
}
try {
$response = $this->graph->post(
"/deviceManagement/configurationPolicies/{$policyId}/assignments",
$assignment
);
// 201 Created = success
$outcomes[] = ['status' => 'success', 'assignment' => $assignment];
// Audit log
AuditLog::create([
'action' => 'restore.assignment.created',
'resource_type' => 'assignment',
'resource_id' => $response['id'],
'metadata' => $assignment,
]);
} catch (GraphException $e) {
$outcomes[] = [
'status' => 'failed',
'assignment' => $assignment,
'error' => $e->getMessage(),
'request_id' => $e->getRequestId(),
];
Log::error("Failed to restore assignment", [
'policy_id' => $policyId,
'assignment' => $assignment,
'error' => $e->getMessage(),
]);
}
// Rate limit protection: 100ms delay between POSTs
usleep(100000);
}
return $outcomes;
}
}
```
**Outcome Reporting**:
```php
$successCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'success'));
$failedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'failed'));
$skippedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'skipped'));
Notification::make()
->title("Assignment Restore Complete")
->body("{$successCount} of {$total} assignments restored. {$failedCount} failed, {$skippedCount} skipped.")
->success()
->send();
```
**Why DELETE-then-CREATE vs PATCH**?
- PATCH requires knowing existing assignment IDs (not available from backup)
- DELETE-then-CREATE is idempotent (can rerun safely)
- Graph API doesn't support "upsert" for assignments
---
### 6. How to handle Scope Tags?
**Question**: Scope Tags are simpler than assignments (just an array of IDs in policy payload). How should we handle them?
**Requirements**:
- Extract `roleScopeTagIds` array from policy payload during backup
- Resolve Scope Tag IDs to names for display
- Preserve IDs during restore (if they exist in target tenant)
- Warn if Scope Tag doesn't exist in target (don't block restore)
**Implementation**:
```php
// During Backup
class AssignmentBackupService
{
public function backupScopeTags(array $policyPayload): array
{
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0']; // Default Scope Tag
// Resolve names (cached for 1 hour)
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds);
return [
'scope_tag_ids' => $scopeTagIds,
'scope_tag_names' => array_column($scopeTags, 'displayName'),
];
}
}
// During Restore
class AssignmentRestoreService
{
public function validateScopeTags(array $scopeTagIds, string $targetTenantId): array
{
$targetScopeTags = $this->scopeTagResolver->getAllForTenant($targetTenantId);
$targetScopeTagIds = array_column($targetScopeTags, 'id');
$matched = array_intersect($scopeTagIds, $targetScopeTagIds);
$missing = array_diff($scopeTagIds, $targetScopeTagIds);
if (!empty($missing)) {
Log::warning("Some Scope Tags not found in target tenant", [
'missing' => $missing,
'matched' => $matched,
]);
}
return [
'matched' => $matched,
'missing' => $missing,
'can_proceed' => true, // Always allow restore
];
}
}
```
**Caching**:
```php
class ScopeTagResolver
{
public function resolve(array $scopeTagIds): array
{
return Cache::remember("scope_tags:all", 3600, function () {
return $this->graph->get('/deviceManagement/roleScopeTags?$select=id,displayName')['value'];
});
}
}
```
**Display in UI**:
```php
// Restore Preview
Scope Tags:
✅ 2 matched (Default, HR-Admins)
⚠️ 1 not found in target (Finance-Admins)
Note: Restore will proceed. Missing Scope Tags will be created automatically by Graph API or ignored.
```
---
### 7. What's the testing strategy?
**Question**: How do we ensure reliability across all scenarios?
**Test Pyramid**:
```
/\
/ \
/ UI \ Browser Tests (5)
/------\ - Group Mapping wizard flow
/ Feature \ Feature Tests (10)
/----------\ - Backup with assignments
/ Unit \ - Policy view rendering
/--------------\ - Restore with mapping
- Assignment fetch failures
Unit Tests (15)
- AssignmentFetcher
- GroupResolver
- ScopeTagResolver
- Model accessors
- Service methods
```
**Key Test Scenarios**:
1. **Unit Tests** (Fast, isolated):
```php
it('fetches assignments with fallback on empty response', function () {
$fetcher = new AssignmentFetcher($this->mockGraph);
// Mock primary call returning empty
$this->mockGraph
->shouldReceive('get')
->with('/deviceManagement/configurationPolicies/abc-123/assignments')
->andReturn(['value' => []]);
// Mock fallback call returning assignments
$this->mockGraph
->shouldReceive('get')
->with('/deviceManagement/configurationPolicies', [...])
->andReturn(['value' => [['assignments' => [...]]]]);
$result = $fetcher->fetch('tenant-1', 'abc-123');
expect($result)->toHaveCount(3);
});
```
2. **Feature Tests** (Integration):
```php
it('backs up policy with assignments when checkbox enabled', function () {
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->for($tenant)->create();
// Mock Graph API
Http::fake([
'*/assignments' => Http::response(['value' => [...]]),
'*/roleScopeTags' => Http::response(['value' => [...]]),
]);
$backupItem = (new AssignmentBackupService)->backup(
tenantId: $tenant->id,
policyId: $policy->graph_id,
includeAssignments: true
);
expect($backupItem->assignments)->toHaveCount(3);
expect($backupItem->metadata['assignment_count'])->toBe(3);
});
```
3. **Browser Tests** (E2E):
```php
it('allows group mapping during restore wizard', function () {
$page = visit('/restore/wizard');
$page->assertSee('Group Mapping Required')
->fill('source-group-abc-123', 'target-group-xyz-789')
->click('Continue')
->assertSee('Restore Preview')
->click('Restore')
->assertSee('Restore Complete');
$restoreRun = RestoreRun::latest()->first();
expect($restoreRun->group_mapping)->toHaveKey('source-group-abc-123');
});
```
**Manual QA Checklist**:
- [ ] Create backup with assignments (same tenant)
- [ ] Create backup without assignments
- [ ] View policy with assignments tab
- [ ] Restore to same tenant (auto-match groups)
- [ ] Restore to different tenant (group mapping wizard)
- [ ] Handle orphaned group IDs gracefully
- [ ] Skip assignments during group mapping
- [ ] Handle Graph API failures (assignments fetch, group resolution)
---
## Technology Stack Summary
| Component | Technology | Justification |
|-----------|-----------|---------------|
| **Assignment Storage** | PostgreSQL JSONB | Immutable snapshots, matches Graph API format |
| **Graph API Fallback** | Primary + Fallback pattern | Production-tested reliability |
| **Group Resolution** | POST `/directoryObjects/getByIds` + Cache | Batch efficiency, 5min TTL |
| **Group Mapping UX** | Filament Searchable Select + Debounce | Supports 500+ groups, responsive |
| **Restore Pattern** | DELETE-then-CREATE | Idempotent, no ID tracking needed |
| **Scope Tag Handling** | Extract from payload + Cache | Simple, 1hr TTL |
| **Error Handling** | Fail-soft + Per-item logging | Graceful degradation |
| **Caching** | Laravel Cache (Redis/File) | 5min groups, 1hr scope tags |
---
## Performance Benchmarks
**Target Metrics**:
- Assignment fetch: < 2 seconds per policy (with fallback)
- Group resolution: < 1 second for 100 groups (batched)
- Group mapping UI: < 500ms search response with 500+ groups (debounced)
- Assignment restore: < 30 seconds for 20 policies (sequential with 100ms delay)
**Optimization Opportunities**:
1. Pre-warm group cache when wizard opens
2. Batch assignment POSTs (if Graph supports batch endpoint - check docs)
3. Use queues for large restores (20+ policies)
4. Add progress polling for long-running restores
---
## Open Questions (For Implementation)
1. **Smart Matching**: Should we auto-suggest group mappings based on name similarity?
- **Defer**: Manual mapping MVP, consider for Phase 2
2. **Dynamic Groups**: How to handle membership rules?
- **Defer**: Skip initially, document as limitation
3. **Assignment Filters**: Do we preserve device/user filters?
- **Yes**: Store entire assignment object in JSONB
4. **Batch API**: Does Graph support batch assignment POST?
- **Research**: Check Graph API docs for `/batch` endpoint support
---
**Status**: Research Complete
**Next Document**: `data-model.md`

View File

@ -28,59 +28,58 @@ ## Scope
- **Policy Types**: `settingsCatalogPolicy` only (initially) - **Policy Types**: `settingsCatalogPolicy` only (initially)
- **Graph Endpoints**: - **Graph Endpoints**:
- GET `/deviceManagement/configurationPolicies/{id}/assignments` - GET `/deviceManagement/configurationPolicies/{id}/assignments`
- POST/PATCH `/deviceManagement/configurationPolicies/{id}/assign` - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments)
- DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback)
- GET `/deviceManagement/roleScopeTags` (for reference data) - GET `/deviceManagement/roleScopeTags` (for reference data)
- **Backup Behavior**: Optional (checkbox "Include Assignments & Scope Tags") - GET `/deviceManagement/assignmentFilters` (for filter names)
- **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true)
- **Restore Behavior**: With group mapping UI for unresolved group IDs - **Restore Behavior**: With group mapping UI for unresolved group IDs
--- ---
## User Stories ## User Stories
### User Story 1 - Backup with Assignments & Scope Tags (Priority: P1) ### User Story 1 - Capture Assignments & Scope Tags (Priority: P1)
**As an admin**, I want to optionally include assignments and scope tags when backing up Settings Catalog policies, so that I have complete policy state for migration or disaster recovery. **As an admin**, I want to optionally include assignments and scope tags when capturing policy snapshots or adding policies to a Backup Set, so that I have complete policy state for migration or disaster recovery.
**Acceptance Criteria:** **Acceptance Criteria:**
1. **Given** I create a new Backup Set for Settings Catalog policies, 1. **Given** I add policies to a Backup Set (or capture a snapshot from the Policy view),
**When** I enable the checkbox "Include Assignments & Scope Tags", **When** I enable "Include assignments" and/or "Include scope tags",
**Then** the backup captures: **Then** the capture stores:
- Assignment list (groups, users, devices with include/exclude mode) - Assignments on the PolicyVersion (include/exclude targets + filters)
- Scope Tag IDs referenced by the policy - Scope tags on the PolicyVersion as `{ids, names}`
- Metadata about assignment count and scope tag names - A BackupItem linked via `policy_version_id` that copies assignments for restore
2. **Given** I view a Backup Set with assignments included, 2. **Given** I create a Backup Set,
**When** I expand a Backup Item detail, **When** I complete the form,
**Then** I see: **Then** no assignments/scope tags checkbox appears on that screen (selection happens when adding policies).
- "Assignments: 3 groups, 2 users" summary
- "Scope Tags: Default, HR-Admins" list
- JSON tab with full assignment payload
3. **Given** I create a Backup Set without enabling the checkbox, 3. **Given** I disable either checkbox,
**When** the backup completes, **When** the capture completes,
**Then** assignments and scope tags are NOT captured (payload-only backup) **Then** the corresponding PolicyVersion fields are `null` and the BackupItem is created without those data.
--- ---
### User Story 2 - Policy View with Assignments Tab (Priority: P1) ### User Story 2 - Policy Version View with Assignments (Priority: P1)
**As an admin**, I want to see a policy's current assignments and scope tags in the Policy View, so I understand its targeting and visibility. **As an admin**, I want to see a policy version's captured assignments and scope tags, so I understand targeting and visibility at that snapshot.
**Acceptance Criteria:** **Acceptance Criteria:**
1. **Given** I view a Settings Catalog policy, 1. **Given** I view a Settings Catalog Policy Version,
**When** I navigate to the "Assignments" tab, **When** assignments were captured,
**Then** I see: **Then** I see:
- Table with columns: Type (Group/User/Device), Name, Mode (Include/Exclude), ID - Include/Exclude group targets with group display name (or "Unknown Group (ID: ...)")
- "Scope Tags" section showing: Default, HR-Admins (editable IDs) - Filter name (if present) with filter mode (include/exclude)
- "Not assigned" message if no assignments exist - Scope tags list from the version
2. **Given** a policy has 10 assignments, 2. **Given** assignments were not captured for this version,
**When** I filter by "Include only" or "Exclude only", **When** I open the assignments panel,
**Then** the table filters accordingly **Then** I see "Assignments were not captured for this version."
3. **Given** assignments include deleted groups (orphaned IDs), 3. **Given** scope tags were not captured,
**When** I view the assignments tab, **When** I view the version,
**Then** orphaned entries show as "Unknown Group (ID: abc-123)" with warning badge **Then** I see a "Scope tags not captured" empty state.
--- ---
@ -135,45 +134,51 @@ ## Functional Requirements
### Backup & Storage ### Backup & Storage
**FR-004.1**: System MUST provide a checkbox "Include Assignments & Scope Tags" on the Backup Set creation form (default: unchecked). **FR-004.1**: System MUST provide separate checkboxes "Include assignments" and "Include scope tags" on:
- Add Policies to Backup Set action
- Capture snapshot action in Policy view
Defaults: checked. Backup Set creation form MUST NOT show these checkboxes.
**FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy: **FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy:
1. Try: `/deviceManagement/configurationPolicies/{id}/assignments` 1. Try: `/deviceManagement/configurationPolicies/{id}/assignments`
2. If empty/fails: Try `$expand=assignments` on policy fetch 2. If empty/fails: Try `$expand=assignments` on policy fetch
3. Store: 3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true` in PolicyVersion metadata.
- Assignment array (each with: `target` object, `id`, `intent`, filters) - This flag covers any failure during assignment capture/enrichment (fetch, group resolution, filter resolution).
- Extracted metadata: group names (resolved via `/directoryObjects/getByIds`), user UPNs, device IDs
- Warning flags for orphaned IDs
- Fallback flag: `assignments_fetch_method` (direct | expand | failed)
**FR-004.3**: System MUST store Scope Tag IDs in backup metadata (from policy payload `roleScopeTagIds` field). **FR-004.3**: System MUST enrich assignments with:
- Group display name + orphaned flag via `/directoryObjects/getByIds`
- Assignment filter name via `/deviceManagement/assignmentFilters`
- Preserve target type (include/exclude) and filter mode (`deviceAndAppManagementAssignmentFilterType`)
- If filter name lookup fails or filter ID is unknown, keep filter ID + mode, omit the name, and continue capture (UI displays filter ID when name is missing).
**FR-004.4**: Backup Item `metadata` JSONB field MUST include: **FR-004.4**: System MUST store assignments and scope tags on PolicyVersion:
```json - `policy_versions.assignments` (array, nullable)
{ - `policy_versions.scope_tags` as `{ids: [], names: []}` (nullable)
"assignment_count": 5, - hashes for deduplication (`assignments_hash`, `scope_tags_hash`)
"scope_tag_ids": ["0", "abc-123"], BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore.
"scope_tag_names": ["Default", "HR-Admins"],
"has_orphaned_assignments": false
}
```
**FR-004.5**: System MUST gracefully handle Graph API failures when fetching assignments (log warning, continue backup with flag `assignments_fetch_failed: true`). **FR-004.5**: PolicyVersion metadata MUST include capture flags (see Data Model). BackupItem metadata MAY mirror these flags for display/audit, but PolicyVersion is the source of truth. Assignment counts are derived from `assignments` at display time.
### UI Display ### UI Display
**FR-004.6**: Policy View MUST show an "Assignments" tab for Settings Catalog policies displaying: **FR-004.6**: Policy Version view MUST show an assignments panel for Settings Catalog versions displaying:
- Assignments table (type, name, mode, ID) - Include/Exclude targets with group display name or "Unknown Group (ID: ...)"
- Assignment filter name + filter mode (include/exclude) when present
- Scope Tags section - Scope Tags section
- Empty state if no assignments - Empty state if assignments or scope tags were not captured
**FR-004.7**: Backup Item detail view MUST show assignment count and scope tag names in metadata summary. **FR-004.7**: Backup Set items table MUST show assignment count (derived from `backup_items.assignments`) and scope tag names from the linked PolicyVersion.
**FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon. **FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon.
**Terminology**:
- **Orphaned group ID**: A group ID referenced in assignments that cannot be resolved in the source tenant during capture.
- **Unresolved group ID**: A group ID not found in the target tenant during restore mapping.
- UI SHOULD render both as "Unknown Group (ID: ...)" with warning styling.
### Restore with Group Mapping ### Restore with Group Mapping
**FR-004.9**: Restore preview MUST detect unresolved group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved. **FR-004.9**: Restore preview MUST detect unresolved (target-missing) group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved.
**FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing: **FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing:
- Source group (name from backup metadata or ID if name unavailable) - Source group (name from backup metadata or ID if name unavailable)
@ -186,15 +191,18 @@ ### Restore with Group Mapping
1. Replace source group IDs with mapped target group IDs in assignment objects 1. Replace source group IDs with mapped target group IDs in assignment objects
2. Skip assignments marked "Skip" in group mapping 2. Skip assignments marked "Skip" in group mapping
3. Preserve include/exclude intent and filters 3. Preserve include/exclude intent and filters
4. Execute restore via DELETE-then-CREATE pattern: 4. Execute restore via assign action when supported:
- Step 1: GET existing assignments from target policy - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
- Step 3: POST each new/mapped assignment (via POST `/assignments`) - GET existing assignments from target policy
- DELETE each existing assignment (via DELETE `/assignments/{id}`)
- POST each new/mapped assignment (via POST `/assignments`)
5. Handle failures gracefully: 5. Handle failures gracefully:
- 204 No Content on DELETE = success - 204 No Content on DELETE = success
- 201 Created on POST = success - 201 Created on POST = success
- Log request-id/client-request-id on any failure - Log request-id/client-request-id on any failure
6. Continue with remaining assignments if one fails (fail-soft) 6. Continue with remaining assignments if one fails (fail-soft)
7. Restore is best-effort: no transactional rollback between DELETE and POST. If DELETE succeeds but POST fails, record a failed outcome, mark the restore as partial, and allow retry.
**FR-004.13**: System MUST handle assignment restore failures gracefully: **FR-004.13**: System MUST handle assignment restore failures gracefully:
- Log per-assignment outcome (success/skip/failure) - Log per-assignment outcome (success/skip/failure)
@ -213,9 +221,9 @@ ### Scope Tags
**FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour). **FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour).
**FR-004.17**: During restore, system SHOULD preserve Scope Tag IDs if they exist in target tenant, or: **FR-004.17**: During restore, system MUST preserve Scope Tag IDs that exist in the target tenant. If a Scope Tag ID is missing:
- Log warning if Scope Tag ID doesn't exist in target - Log a warning
- Allow policy creation to proceed (Graph API default behavior) - Proceed without that tag (best-effort, Graph API default behavior)
**FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant". **FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant".
@ -223,7 +231,7 @@ ### Scope Tags
## Non-Functional Requirements ## Non-Functional Requirements
**NFR-004.1**: Assignment fetching MUST NOT block backup creation (async or fail-soft). **NFR-004.1**: Assignment fetching MUST NOT block capture actions (Add Policies / Capture Snapshot). Use async or fail-soft behavior.
**NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups. **NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups.
@ -235,12 +243,33 @@ ## Non-Functional Requirements
## Data Model Changes ## Data Model Changes
### Migration: `backup_items` table extension ### Migration: `policy_versions` assignments + scope tags
```php
Schema::table('policy_versions', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata');
$table->json('scope_tags')->nullable()->after('assignments');
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
$table->index('assignments_hash');
$table->index('scope_tags_hash');
});
```
### Migration: `backup_items` policy_version_id
```php
Schema::table('backup_items', function (Blueprint $table) {
$table->foreignId('policy_version_id')->nullable()->constrained('policy_versions');
});
```
### Migration: `backup_items` assignments copy
```php ```php
Schema::table('backup_items', function (Blueprint $table) { Schema::table('backup_items', function (Blueprint $table) {
$table->json('assignments')->nullable()->after('metadata'); $table->json('assignments')->nullable()->after('metadata');
// stores: [{target:{...}, id, intent, filters}, ...] // copy of PolicyVersion assignments for restore safety
}); });
``` ```
@ -253,18 +282,28 @@ ### Migration: `restore_runs` table extension
}); });
``` ```
### `backup_items.metadata` JSONB schema ### `policy_versions.scope_tags` JSONB schema
```json ```json
{ {
"assignment_count": 5, "ids": ["0", "123"],
"scope_tag_ids": ["0", "123"], "names": ["Default", "HR"]
"scope_tag_names": ["Default", "HR"], }
```
### `policy_versions.metadata` JSONB schema
```json
{
"has_assignments": true,
"has_scope_tags": true,
"has_orphaned_assignments": false, "has_orphaned_assignments": false,
"assignments_fetch_failed": false "assignments_fetch_failed": false
} }
``` ```
BackupItem metadata MAY include the same flags copied from the PolicyVersion for display/audit, but PolicyVersion is the source of truth.
--- ---
## Graph API Integration ## Graph API Integration
@ -280,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
- Client-side filter to extract assignments - Client-side filter to extract assignments
- **Reason**: Known Graph API quirks with assignment expansion on certain template families - **Reason**: Known Graph API quirks with assignment expansion on certain template families
2. **Assignment CRUD Operations** (Standard Graph Pattern) 2. **Assignment Apply** (Assign action + fallback)
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` - **POST** `/deviceManagement/configurationPolicies/{id}/assign`
- Body: Single assignment object - Body: `{ "assignments": [ ... ] }`
- Returns: 201 Created with assignment object - Returns: 200/204 on success (no per-assignment IDs)
- Example: - Example:
```json ```json
{ {
"target": { "assignments": [
"@odata.type": "#microsoft.graph.groupAssignmentTarget", {
"groupId": "abc-123-def" "target": {
}, "@odata.type": "#microsoft.graph.groupAssignmentTarget",
"intent": "apply" "groupId": "abc-123-def"
},
"intent": "apply"
}
]
} }
``` ```
- **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - **Fallback** (when `/assign` is unsupported):
- Body: Assignment object (partial update) - **GET** `/deviceManagement/configurationPolicies/{id}/assignments`
- Returns: 200 OK with updated assignment - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object)
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
- Returns: 204 No Content
- **Restore Strategy**: DELETE all existing assignments, then POST new ones (atomic via transaction pattern)
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) 3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
@ -321,6 +362,11 @@ ### Endpoints to Add (Production-Tested Strategies)
- For Scope Tag resolution (cache 1 hour) - For Scope Tag resolution (cache 1 hour)
- Scope Tag IDs also available in policy payload's `roleScopeTagIds` array - Scope Tag IDs also available in policy payload's `roleScopeTagIds` array
5. **GET** `/deviceManagement/assignmentFilters?$select=id,displayName`
- For assignment filter name resolution (cache 1 hour)
- Filter IDs in assignments: `deviceAndAppManagementAssignmentFilterId`
- Filter mode: `deviceAndAppManagementAssignmentFilterType` (include/exclude)
### Graph Contract Updates ### Graph Contract Updates
Add to `config/graph_contracts.php`: Add to `config/graph_contracts.php`:
@ -331,7 +377,7 @@ ### Graph Contract Updates
// Assignments CRUD (standard Graph pattern) // Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',
@ -348,18 +394,17 @@ ### Graph Contract Updates
## UI Mockups (Wireframe Descriptions) ## UI Mockups (Wireframe Descriptions)
### Policy View - Assignments Tab ### Policy Version View - Assignments Panel
``` ```
[General] [Settings] [Assignments] [JSON] [General] [Settings] [JSON]
Assignments (5) Assignments (5)
┌─────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────┐
│ Type │ Name │ Mode │ ID │ │ Type │ Name │ Filter │ ID │
├─────────┼───────────────────┼─────────┼─────────┤ ├─────────┼───────────────────┼─────────┼─────────┤
│ Group │ All Users │ Include │ abc-123 │ │ Include group │ All Users │ Test (include) │ abc-123 │
│ Group │ Contractors │ Exclude │ def-456 │ │ Exclude group │ Contractors │ - │ def-456 │
│ User │ john@contoso.com │ Include │ ghi-789 │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
Scope Tags (2) Scope Tags (2)
@ -367,18 +412,20 @@ ### Policy View - Assignments Tab
• HR-Admins (ID: 123) • HR-Admins (ID: 123)
``` ```
### Backup Creation - Checkbox ### Add Policies to Backup Set - Checkboxes
``` ```
Create Backup Set Add Policies to Backup Set
───────────────── ─────────────────────────
Select Policies: [Settings Catalog: 15 selected] Select Policies: [Settings Catalog: 15 selected]
☑ Include Assignments & Scope Tags ☑ Include assignments
Captures group/user targeting and RBAC scope. Captures include/exclude targeting and filters.
Adds ~2-5 KB per policy with assignments.
[Cancel] [Create Backup] ☑ Include scope tags
Captures policy scope tag IDs.
[Cancel] [Add Policies]
``` ```
### Restore Wizard - Group Mapping Step ### Restore Wizard - Group Mapping Step
@ -408,15 +455,18 @@ ### Unit Tests
- `AssignmentFetcherTest`: Mock Graph responses, test parsing - `AssignmentFetcherTest`: Mock Graph responses, test parsing
- `GroupMapperTest`: Test ID resolution, mapping logic - `GroupMapperTest`: Test ID resolution, mapping logic
- `ScopeTagResolverTest`: Test caching, name resolution - `ScopeTagResolverTest`: Test caching, name resolution
- `AssignmentFilterResolverTest`: Test caching and ID filtering
### Feature Tests ### Feature Tests
- `BackupWithAssignmentsTest`: E2E backup creation with checkbox - `BackupWithAssignmentsConsistencyTest`: PolicyVersion as source of truth
- `PolicyViewAssignmentsTabTest`: UI rendering, orphaned IDs - `VersionCaptureWithAssignmentsTest`: Snapshot capture with assignments/scope tags
- `PolicyVersionViewAssignmentsTest`: UI rendering, orphaned IDs, filters
- `RestoreGroupMappingTest`: Wizard flow, mapping persistence - `RestoreGroupMappingTest`: Wizard flow, mapping persistence
- `RestoreAssignmentApplicationTest`: Graph API calls, outcomes - `RestoreAssignmentApplicationTest`: Graph API calls, outcomes
### Manual QA ### Manual QA
- Create backup with/without assignments checkbox - Add policies to backup set with/without assignments and scope tags
- Capture snapshot with/without assignments and scope tags
- Restore to same tenant (auto-match groups) - Restore to same tenant (auto-match groups)
- Restore to different tenant (group mapping wizard) - Restore to different tenant (group mapping wizard)
- Handle orphaned group IDs gracefully - Handle orphaned group IDs gracefully
@ -426,10 +476,10 @@ ### Manual QA
## Rollout Plan ## Rollout Plan
### Phase 1: Backup with Assignments (MVP) ### Phase 1: Backup with Assignments (MVP)
- Add checkbox to Backup form - Add checkboxes on Add Policies + Capture Snapshot actions
- Fetch assignments from Graph - Fetch assignments from Graph
- Store in `backup_items.assignments` - Store on PolicyVersion (copy assignments to BackupItem)
- Display in Policy View (read-only) - Display in Policy Version view (read-only)
- **Duration**: ~8-12 hours - **Duration**: ~8-12 hours
### Phase 2: Restore with Group Mapping ### Phase 2: Restore with Group Mapping
@ -440,7 +490,7 @@ ### Phase 2: Restore with Group Mapping
### Phase 3: Scope Tags ### Phase 3: Scope Tags
- Resolve Scope Tag names - Resolve Scope Tag names
- Display in UI - Display in Policy Version view
- Handle restore warnings - Handle restore warnings
- **Duration**: ~4-6 hours - **Duration**: ~4-6 hours
@ -461,7 +511,7 @@ ## Risks & Mitigations
| Risk | Mitigation | | Risk | Mitigation |
|------|------------| |------|------------|
| Graph API assignments endpoint slow/fails | Async fetch, fail-soft with warning | | Graph API assignments endpoint slow/fails | Fail-soft with warning |
| Target tenant has 1000+ groups | Searchable dropdown with pagination | | Target tenant has 1000+ groups | Searchable dropdown with pagination |
| Group IDs change across tenants | Group name-based matching fallback | | Group IDs change across tenants | Group name-based matching fallback |
| Scope Tag IDs don't exist in target | Log warning, allow policy creation | | Scope Tag IDs don't exist in target | Log warning, allow policy creation |
@ -470,8 +520,8 @@ ## Risks & Mitigations
## Success Criteria ## Success Criteria
1. ✅ Backup checkbox functional, assignments captured 1. ✅ Capture checkboxes functional, assignments captured
2. ✅ Policy View shows assignments tab with accurate data 2. ✅ Policy Version view shows assignments widget with accurate data
3. ✅ Group Mapping wizard handles 100+ groups smoothly 3. ✅ Group Mapping wizard handles 100+ groups smoothly
4. ✅ Restore applies assignments with 90%+ success rate 4. ✅ Restore applies assignments with 90%+ success rate
5. ✅ Audit logs record all mapping decisions 5. ✅ Audit logs record all mapping decisions

View File

@ -0,0 +1,853 @@
# Feature 004: Assignments & Scope Tags - Task Breakdown
## Overview
This document breaks down the implementation plan into granular, actionable tasks organized by phase and user story.
**Total Estimated Tasks**: ~60 tasks
**MVP Scope**: Tasks marked with ⭐ (updated)
**Full Implementation**: All tasks (~30-40 hours)
---
## Plan Updates (Implementation Reality)
- Assignments + scope tags are stored on PolicyVersion; BackupItem links to PolicyVersion and copies assignments.
- Include assignments/scope tags checkboxes live on Add Policies to Backup Set and Capture Snapshot actions (not on Backup Set creation).
- Policy assignments UI moved to Policy Version view via Livewire widget.
- Assignment filters resolved via `/deviceManagement/assignmentFilters` and displayed with include/exclude mode.
---
## Phase 1: Setup & Database (Foundation)
**Duration**: 2-3 hours
**Dependencies**: None
**Parallelizable**: No (sequential setup)
### Tasks
**1.1** [X] ⭐ Create migration: `add_assignments_to_backup_items`
- File: `database/migrations/xxxx_add_assignments_to_backup_items.php`
- Add `assignments` JSONB column after `metadata`
- Make nullable
- Write reversible `down()` method
- Test: `php artisan migrate` and `migrate:rollback`
**1.2** [X] ⭐ Create migration: `add_group_mapping_to_restore_runs`
- File: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php`
- Add `group_mapping` JSONB column after `results`
- Make nullable
- Write reversible `down()` method
- Test: `php artisan migrate` and `migrate:rollback`
**1.3** [X] ⭐ Update `BackupItem` model with assignments cast
- Add `'assignments' => 'array'` to `$casts`
- Add `assignments` to `$fillable`
- Test: Create BackupItem with assignments, verify cast works
**1.4** [X] ⭐ Add `BackupItem` assignment accessor methods
- `getAssignmentCountAttribute(): int`
- `hasAssignments(): bool`
- `getGroupIdsAttribute(): array`
- `getScopeTagIdsAttribute(): array`
- `getScopeTagNamesAttribute(): array`
- `hasOrphanedAssignments(): bool`
- `assignmentsFetchFailed(): bool`
**1.5** [X] ⭐ Add `BackupItem` scope: `scopeWithAssignments()`
- Filter policies with non-null assignments
- Use `whereNotNull('assignments')` and `whereRaw('json_array_length(assignments) > 0')`
**1.6** [X] ⭐ Update `RestoreRun` model with group_mapping cast
- Add `'group_mapping' => 'array'` to `$casts`
- Add `group_mapping` to `$fillable`
- Test: Create RestoreRun with group_mapping, verify cast works
**1.7** [X] ⭐ Add `RestoreRun` group mapping helper methods
- `hasGroupMapping(): bool`
- `getMappedGroupId(string $sourceGroupId): ?string`
- `isGroupSkipped(string $sourceGroupId): bool`
- `getUnmappedGroupIds(array $sourceGroupIds): array`
- `addGroupMapping(string $sourceGroupId, string $targetGroupId): void`
**1.8** [X] ⭐ Add `RestoreRun` assignment outcome methods
- `getAssignmentRestoreOutcomes(): array`
- `getSuccessfulAssignmentsCount(): int`
- `getFailedAssignmentsCount(): int`
- `getSkippedAssignmentsCount(): int`
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
- Add `assignments_list_path` (GET)
- Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy)
- Add `assignments_delete_path` (DELETE)
- Add `supports_scope_tags: true`
- Add `scope_tag_field: 'roleScopeTagIds'`
**1.10** [X] ⭐ Write unit tests: `BackupItemTest`
- Test assignment accessors
- Test scope `withAssignments()`
- Test metadata helpers (scope tags, orphaned flags)
- Expected: 100% coverage for new methods
**1.11** [X] ⭐ Write unit tests: `RestoreRunTest`
- Test group mapping helpers
- Test assignment outcome methods
- Test `addGroupMapping()` persistence
- Expected: 100% coverage for new methods
**1.12** [X] Run Pint: Format all new code
- `./vendor/bin/pint database/migrations/`
- `./vendor/bin/pint app/Models/BackupItem.php`
- `./vendor/bin/pint app/Models/RestoreRun.php`
**1.13** [X] Verify tests pass
- Run: `php artisan test --filter=BackupItem`
- Run: `php artisan test --filter=RestoreRun`
- Expected: All green
**1.14** [X] ⭐ Add migration: `add_assignments_to_policy_versions`
- Add `assignments`, `scope_tags`, `assignments_hash`, `scope_tags_hash`
- Add indexes on hashes
- Reversible `down()` method
**1.15** [X] ⭐ Add migration: `add_policy_version_id_to_backup_items`
- Add nullable `policy_version_id` FK to `policy_versions`
- Reversible `down()` method
**1.16** [X] ⭐ Update `PolicyVersion` model casts
- Add casts for `assignments` and `scope_tags`
**1.17** [X] ⭐ Add `BackupItem` relation to PolicyVersion
- `policyVersion(): BelongsTo`
---
## Phase 2: Graph API Integration (Core Services)
**Duration**: 4-6 hours
**Dependencies**: Phase 1 complete
**Parallelizable**: Yes (services independent)
### Tasks
**2.1** [X] ⭐ Create service: `AssignmentFetcher`
- File: `app/Services/Graph/AssignmentFetcher.php`
- Method: `fetch(string $tenantId, string $policyId): array`
- Implement primary endpoint: GET `/assignments`
- Implement fallback: GET with `$expand=assignments`
- Return empty array on failure (fail-soft)
- Log warnings with request IDs
**2.2** [X] ⭐ Add error handling to `AssignmentFetcher`
- Catch `GraphException`
- Log: tenant_id, policy_id, error message, request_id
- Return empty array (don't throw)
- Set flag for caller: `assignments_fetch_failed`
**2.3** [X] ⭐ Write unit test: `AssignmentFetcherTest::primary_endpoint_success`
- Mock Graph response with assignments
- Assert returned array matches response
- Assert no fallback called
**2.4** [X] ⭐ Write unit test: `AssignmentFetcherTest::fallback_on_empty_response`
- Mock primary returning empty array
- Mock fallback returning assignments
- Assert fallback called
- Assert assignments returned
**2.5** [X] ⭐ Write unit test: `AssignmentFetcherTest::fail_soft_on_error`
- Mock both endpoints throwing `GraphException`
- Assert empty array returned
- Assert warning logged
**2.6** [X] Create service: `GroupResolver`
- File: `app/Services/Graph/GroupResolver.php`
- Method: `resolveGroupIds(array $groupIds, string $tenantId): array`
- Implement: POST `/directoryObjects/getByIds`
- Return keyed array: `['group-id' => ['id', 'displayName', 'orphaned']]`
- Handle orphaned IDs (not in response)
**2.7** [X] Add caching to `GroupResolver`
- Cache key: `"groups:{$tenantId}:" . md5(implode(',', $groupIds))`
- TTL: 5 minutes
- Use `Cache::remember()`
**2.8** [X] Write unit test: `GroupResolverTest::resolves_all_groups`
- Mock Graph response with all group IDs
- Assert all resolved with names
- Assert `orphaned: false`
**2.9** [X] Write unit test: `GroupResolverTest::handles_orphaned_ids`
- Mock Graph response missing some IDs
- Assert orphaned IDs have `displayName: null`
- Assert `orphaned: true`
**2.10** [X] Write unit test: `GroupResolverTest::caches_results`
- Call resolver twice with same IDs
- Assert Graph API called only once
- Assert cache hit on second call
**2.11** [X] Create service: `ScopeTagResolver`
- File: `app/Services/Graph/ScopeTagResolver.php`
- Method: `resolve(array $scopeTagIds): array`
- Implement: GET `/deviceManagement/roleScopeTags?$select=id,displayName`
- Return array of scope tag objects
- Cache results (1 hour TTL)
**2.12** [X] Add cache to `ScopeTagResolver`
- Cache key: `"scope_tags:all"`
- TTL: 1 hour
- Fetch all scope tags once, filter in memory
**2.13** [X] Write unit test: `ScopeTagResolverTest::resolves_scope_tags`
- Mock Graph response
- Assert correct scope tags returned
- Assert filtered to requested IDs only
**2.14** [X] Write unit test: `ScopeTagResolverTest::caches_results`
- Call resolver twice
- Assert Graph API called only once
- Assert cache hit on second call
**2.15** [X] Run Pint: Format service classes
- `./vendor/bin/pint app/Services/Graph/`
**2.16** [X] Verify service tests pass
- Run: `php artisan test --filter=AssignmentFetcher`
- Run: `php artisan test --filter=GroupResolver`
- Run: `php artisan test --filter=ScopeTagResolver`
- Expected: All green, 90%+ coverage
**2.17** [X] ⭐ Create service: `AssignmentFilterResolver`
- File: `app/Services/Graph/AssignmentFilterResolver.php`
- GET `/deviceManagement/assignmentFilters?$select=id,displayName`
- Cache results (TTL 1 hour)
**2.18** [X] ⭐ Write unit test: `AssignmentFilterResolverTest`
- Assert filter name resolution by ID
- Assert cache behavior
---
## Phase 3: US1 - Capture Assignments & Scope Tags (MVP Core)
**Duration**: 4-5 hours
**Dependencies**: Phase 2 complete
**Parallelizable**: Partially (service and UI separate)
### Tasks
**3.1** [X] ⭐ Add include checkboxes to "Add policies" action
- File: `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
- Components: `include_assignments`, `include_scope_tags` (default: true)
- Help text: assignments include/exclude + filters, scope tag IDs
**3.2** [X] ⭐ Add include checkboxes to "Capture snapshot" action
- File: `app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`
- Components: `include_assignments`, `include_scope_tags` (default: true)
**3.3** [X] ⭐ Create `PolicyCaptureOrchestrator`
- File: `app/Services/Intune/PolicyCaptureOrchestrator.php`
- Capture payload + assignments + scope tags
- Reuse existing PolicyVersion by snapshot hash
- Backfill assignments/scope tags when missing
**3.4** [X] ⭐ Store assignments/scope tags on PolicyVersion
- Update `VersionService::captureVersion` to accept assignments/scopeTags
- Store `assignments_hash` and `scope_tags_hash`
**3.5** [X] ⭐ Link BackupItem to PolicyVersion and copy assignments
- Add `policy_version_id` to backup_items
- BackupService uses orchestrator and copies assignments
- Scope tags live on PolicyVersion only
**3.6** [X] Update BackupSet items table columns
- Eager-load `policyVersion`
- Assignment count derived from `backup_items.assignments`
- Scope tags read from `policyVersion.scope_tags`
**3.7** [X] Add audit log entry: `backup.assignments.included`
- Log when `include_assignments` is enabled
**3.8** [X] ⭐ Write/Update feature tests
- `BackupWithAssignmentsConsistencyTest`
- `VersionCaptureWithAssignmentsTest`
- `PolicyVersionViewAssignmentsTest`
---
## Phase 4: US2 - Policy Version View with Assignments
**Duration**: 3-4 hours
**Dependencies**: Phase 3 complete
**Parallelizable**: Yes (independent of Phase 5)
### Tasks
**4.1** [X] ⭐ Create assignments widget for PolicyVersion view
- Livewire component: `PolicyVersionAssignmentsWidget`
- Render via `ViewPolicyVersion::getFooterWidgets`
**4.2** [X] Show include/exclude groups and filters
- Map target types to Include/Exclude labels
- Render group display name or "Unknown Group (ID: ...)"
- Show assignment filter name + filter mode
**4.3** [X] Show scope tags section
- Use `PolicyVersion.scope_tags['names']`
- Empty state when not captured
**4.4** [X] Remove assignments UI from Policy view
- Policy view no longer shows assignments (moved to PolicyVersion view)
**4.5** [X] ⭐ Update tests
- `PolicyVersionViewAssignmentsTest`
---
## Phase 5: US3 - Restore with Group Mapping (Core Restore)
**Duration**: 6-8 hours
**Dependencies**: Phase 4 complete
**Parallelizable**: No (complex, sequential)
### Tasks
**5.1** Detect unresolved groups in restore preview
- File: `app/Filament/Resources/RestoreResource/Pages/RestorePreview.php`
- Extract group IDs from source assignments
- Call `POST /directoryObjects/getByIds` for target tenant
- Compare: missing IDs = unresolved
**5.2** Create Filament wizard component: `GroupMappingStep`
- File: `app/Filament/Forms/Components/GroupMappingStep.php`
- Add as step 2 in restore wizard (between Preview and Confirm)
- Show only if unresolved groups exist
**5.3** Build group mapping table in wizard step
- Use Filament `Repeater` or custom table
- Columns: Source Group (name or ID), Target Group (dropdown), Skip (checkbox)
- Populate source groups from backup metadata
**5.4** Implement target group dropdown
- Use `Select::make('target_group_id')`
- `->searchable()`
- `->getSearchResultsUsing()` to query target tenant groups
- `->debounce(500)` for responsive search
- `->lazy()` to load options on demand
**5.5** Add "Skip" checkbox functionality
- When checked, set mapping value to `"SKIP"`
- Disable target group dropdown when skip checked
**5.6** Cache target tenant groups
- In wizard `mount()`, call `GroupResolver->getAllForTenant($targetTenantId)`
- Cache for 5 minutes
- Pre-warm dropdown options
**5.7** Persist group mapping to `RestoreRun`
- On wizard step 2 submit, save mapping to `$restoreRun->group_mapping`
- Use `RestoreRun->addGroupMapping()` helper
- Validate: all unresolved groups either mapped or skipped
**5.8** Create service: `AssignmentRestoreService`
- File: `app/Services/AssignmentRestoreService.php`
- Method: `restore(string $policyId, array $assignments, array $groupMapping): array`
- Prefer `/assign` action when supported; fallback to DELETE-then-CREATE pattern
**5.9** Implement DELETE existing assignments (fallback)
- Step 1: GET `/assignments` for target policy
- Step 2: Loop and DELETE each assignment
- Handle 204 No Content (success)
- Log warnings on failure, continue
**5.10** Implement CREATE new assignments with mapping (fallback)
- Step 3: Loop through source assignments
- Apply group mapping: replace source group IDs with target IDs
- Skip assignments marked `"SKIP"` in mapping
- POST each assignment to `/assignments`
- Handle 201 Created (success)
- Log per-assignment outcome
**5.11** Add rate limit protection (fallback only)
- Add 100ms delay between sequential POST calls: `usleep(100000)`
- Log request IDs for failed calls
**5.12** Handle per-assignment failures gracefully
- Don't throw on failure, collect outcomes
- Outcomes array: `['status' => 'success|failed|skipped', 'assignment' => ..., 'error' => ...]`
- Continue with remaining assignments (fail-soft)
**5.13** Store outcomes in `RestoreRun->results`
- Add `assignment_outcomes` key to results JSON
- Include successful, failed, skipped counts
- Store error details for failed assignments
**5.14** Add audit log entries
- `restore.group_mapping.applied`: When mapping saved
- `restore.assignment.created`: Per successful assignment
- `restore.assignment.failed`: Per failed assignment
- `restore.assignment.skipped`: Per skipped assignment
**5.15** Create job: `RestoreAssignmentsJob`
- File: `app/Jobs/RestoreAssignmentsJob.php`
- Dispatch async after restore initiated
- Call `AssignmentRestoreService`
- Handle job failures (log, mark RestoreRun as failed)
**5.16** Write feature test: `RestoreGroupMappingTest::detects_unresolved_groups`
- Create backup with group IDs
- Target tenant has different groups
- Start restore wizard
- Assert group mapping step appears
- Assert unresolved groups listed
**5.17** Write feature test: `RestoreGroupMappingTest::persists_group_mapping`
- Fill out group mapping form
- Submit wizard
- Assert `RestoreRun->group_mapping` populated
- Assert audit log entry created
**5.18** Write feature test: `RestoreGroupMappingTest::skips_mapped_groups`
- Map some groups, skip others
- Complete restore
- Assert skipped groups have `"SKIP"` value
- Assert skipped assignments not created
**5.19** Write feature test: `RestoreAssignmentApplicationTest::applies_assignments_successfully`
- Mock Graph API (DELETE 204, POST 201)
- Restore with mapped groups
- Assert assignments created in target tenant
- Assert outcomes logged
**5.20** Write feature test: `RestoreAssignmentApplicationTest::handles_failures_gracefully`
- Mock Graph API: some POSTs fail (400 or 500)
- Restore with mapped groups
- Assert successful assignments still created
- Assert failed assignments logged with errors
- Assert RestoreRun status reflects partial success
**5.21** Run Pint: Format restore code
- `./vendor/bin/pint app/Services/AssignmentRestoreService.php`
- `./vendor/bin/pint app/Jobs/RestoreAssignmentsJob.php`
- `./vendor/bin/pint app/Filament/Forms/Components/GroupMappingStep.php`
**5.22** Verify feature tests pass
- Run: `php artisan test --filter=RestoreGroupMapping`
- Run: `php artisan test --filter=RestoreAssignmentApplication`
- Expected: All green
---
## Phase 6: US4 - Restore Preview with Assignment Diff
**Duration**: 2-3 hours
**Dependencies**: Phase 5 complete
**Parallelizable**: Yes (extends Phase 5, doesn't block)
### Tasks
**6.1** Fetch target policy's current assignments
- In restore preview, call `AssignmentFetcher` for target policy
- Cache for duration of wizard session
**6.2** Implement diff algorithm
- Compare source assignments with target assignments
- Group by change type:
- **Added**: In source, not in target
- **Removed**: In target, not in source
- **Unchanged**: In both (match by group ID + intent)
**6.3** Display diff in Restore Preview
- Use Filament `Infolist` with color coding:
- Green (success): Added assignments
- Red (danger): Removed assignments
- Gray (secondary): Unchanged assignments
- Show counts: "3 added, 1 removed, 2 unchanged"
**6.4** Add Scope Tag diff
- Compare source scope tag IDs with target tenant scope tags
- Display: "Scope Tags: 2 matched, 1 not found in target"
- Show warning icon for missing scope tags
**6.5** Update feature test: `RestoreAssignmentApplicationTest::displays_assignment_diff`
- Visit restore preview
- Assert diff sections visible
- Assert color coding correct
- Assert counts accurate
**6.6** Run Pint: Format diff code
- `./vendor/bin/pint app/Filament/Resources/RestoreResource/Pages/RestorePreview.php`
**6.7** Verify test passes
- Run: `php artisan test --filter=displays_assignment_diff`
- Expected: Green
---
## Phase 7: Scope Tags (Full Support)
**Duration**: 2-3 hours
**Dependencies**: Phase 6 complete
**Parallelizable**: Yes (extends existing code)
### Tasks
**7.1** Extract scope tag IDs during backup
- In `AssignmentBackupService`, read `roleScopeTagIds` from policy payload
- Store in `metadata['scope_tag_ids']`
**7.2** Resolve scope tag names during backup
- Call `ScopeTagResolver->resolve($scopeTagIds)`
- Store in `metadata['scope_tag_names']`
**7.3** Validate scope tags during restore
- In `AssignmentRestoreService`, check if scope tag IDs exist in target tenant
- Call `POST /directoryObjects/getByIds` with type `['scopeTag']` (if supported) or GET all scope tags
**7.4** Log warnings for missing scope tags
- Log: "Scope Tag '{id}' not found in target tenant, restore will proceed"
- Don't block restore (Graph API handles missing scope tags gracefully)
**7.5** Update unit test: `ScopeTagResolverTest::handles_missing_scope_tags`
- Mock Graph response with some scope tags missing
- Assert warnings logged
- Assert restore proceeds
**7.6** Update feature test: `RestoreAssignmentApplicationTest::handles_missing_scope_tags`
- Restore with scope tag IDs not in target tenant
- Assert warnings logged
- Assert policy created successfully
**7.7** Run Pint: Format scope tag code
**7.8** Verify tests pass
- Run: `php artisan test --filter=ScopeTag`
- Expected: Green
---
## Phase 8: Polish & Performance
**Duration**: 3-4 hours
**Dependencies**: Phase 7 complete
**Parallelizable**: Partially (UI polish vs performance tuning)
### Tasks
**8.1** Add loading indicators to group mapping dropdown
- Use `wire:loading` on Select component
- Show spinner during search
- Disable dropdown while loading
**8.2** Add debouncing to group search
- Set `->debounce(500)` on Select component
- Test: Type quickly, verify search only fires after 500ms pause
**8.3** Optimize Graph API calls: batch group resolution
- In `GroupResolver`, batch max 100 IDs per POST
- If > 100 groups, split into chunks: `collect($groupIds)->chunk(100)`
- Merge results
**8.4** Add cache warming for target tenant groups
- In wizard `mount()`, pre-fetch all target tenant groups
- Cache for 5 minutes
- Display loading message while warming
**8.5** Add tooltips to UI elements
- Include assignments checkbox: "Captures include/exclude targeting and filters."
- Include scope tags checkbox: "Captures policy scope tag IDs."
- Group mapping: "Map source groups to target groups for cross-tenant migrations."
- Skip checkbox: "Don't restore assignments targeting this group."
**8.6** Update README: Add "Assignments & Scope Tags" section
- Overview of feature
- How to use include assignments/scope tags (Add Policies + Capture snapshot)
- How to use group mapping wizard
- Troubleshooting tips
**8.7** Create performance test: Large restore
- Restore 50 policies with 10 assignments each
- Measure duration
- Target: < 5 minutes
- Log: Graph API call count, cache hits
**8.8** Run performance test
- Execute test
- Record results in `quickstart.md`
- Optimize if needed (e.g., increase cache TTL)
**8.9** Run Pint: Format all code
**8.10** Final test run
- Run: `php artisan test`
- Expected: All tests green, 85%+ coverage
---
## Phase 9: Testing & QA
**Duration**: 2-3 hours
**Dependencies**: Phase 8 complete
**Parallelizable**: No (QA sequential)
### Tasks
**9.1** Manual QA: Add policies with assignments (same tenant)
- Open a Backup Set and add policies
- Enable "Include assignments" (and scope tags if desired)
- Verify add completes
- Verify assignments stored in DB
- Verify audit log entry
**9.2** Manual QA: Add policies without assignments
- Add policies to a Backup Set or capture a snapshot
- Disable "Include assignments"
- Verify add/capture completes
- Verify assignments column is null
**9.3** Manual QA: Policy Version view with assignments widget
- Navigate to a Policy Version with assignments
- Verify widget renders
- Verify orphaned IDs show warning
- Verify scope tags section
**9.4** Manual QA: Restore to same tenant (auto-match)
- Restore backup to original tenant
- Verify no group mapping step appears
- Verify assignments restored correctly
**9.5** Manual QA: Restore to different tenant (group mapping)
- Restore backup to different tenant
- Verify group mapping step appears
- Fill out mapping (map some, skip others)
- Verify assignments restored with mapped IDs
- Verify skipped assignments not created
**9.6** Manual QA: Handle orphaned group IDs
- Create backup with group ID that doesn't exist in target
- Restore to target tenant
- Verify warning displayed
- Verify orphaned group rendered as "Unknown Group"
**9.7** Manual QA: Handle Graph API failures
- Simulate API failure (disable network or use Http::fake with 500 response)
- Attempt add policies or capture snapshot with "Include assignments"
- Verify fail-soft behavior
- Verify warning logged
**9.8** Browser test: `GroupMappingWizardTest`
- Use Pest browser testing
- Navigate restore wizard
- Fill out group mapping
- Submit
- Verify mapping persisted
**9.9** Load testing: 100+ policies
- Create 100 policies with 20 assignments each
- Restore to target tenant
- Measure duration
- Verify < 5 minutes
**9.10** Staging deployment
- Deploy to staging via Dokploy
- Run manual QA scenarios on staging
- Verify no issues
**9.11** Document QA results
- Update `quickstart.md` with QA checklist results
- Note any issues or edge cases discovered
---
## Phase 10: Deployment & Documentation
**Duration**: 1-2 hours
**Dependencies**: Phase 9 complete
**Parallelizable**: No (sequential deployment)
### Tasks
**10.1** Review deployment checklist
- Migrations ready? ✅
- Rollback tested? ✅
- Env vars needed? (None for this feature)
- Queue workers running? ✅
**10.2** Run migrations on staging
- SSH to staging server
- Run: `php artisan migrate`
- Verify no errors
- Check backup_items and restore_runs tables
**10.3** Smoke test on staging
- Add policies to a Backup Set with "Include assignments"
- Restore to same tenant
- Verify success
**10.4** Update spec.md with implementation notes
- Add "Implementation Status" section
- Note any deviations from original spec
- Document known limitations (e.g., Settings Catalog only in Phase 1)
**10.5** Create migration guide
- Document: "Existing backups will NOT have assignments (not retroactive)"
- Document: "Re-add policies or capture snapshots with include assignments/scope tags to capture data"
**10.6** Add monitoring alerts
- Alert: Assignment fetch failure rate > 10%
- Alert: Group resolution failure rate > 5%
- Use Laravel logs or external monitoring (e.g., Sentry)
**10.7** Production deployment
- Deploy to production via Dokploy
- Run migrations
- Monitor logs for 24 hours
- Check for errors
**10.8** Verify no rate limit issues
- Monitor Graph API headers: `X-RateLimit-Remaining`
- If rate limiting detected, increase delays (currently 100ms)
**10.9** Final documentation update
- Update README with feature status
- Link to quickstart.md for developers
- Add to feature list
**10.10** Close tasks in tasks.md
- Mark all tasks complete ✅
- Archive planning docs (optional)
---
## Task Summary
### MVP Scope (⭐ Tasks)
**Total**: ~24 tasks
**Estimated**: 16-22 hours
**Breakdown**:
- Phase 1 (Setup): 17 tasks (2-3h)
- Phase 2 (Graph Services): 18 tasks (4-6h)
- Phase 3 (Capture): 8 tasks (4-5h)
- Phase 4 (Policy Version view): 5 tasks (3-4h)
- Phase 5 (Restore, basic): Partial (subset for same-tenant restore)
**MVP Scope Definition**:
- ✅ Capture assignments/scope tags in Add Policies + Capture Snapshot (US1)
- ✅ Policy Version assignments widget (US2)
- ✅ Basic restore (same tenant, auto-match) (US3 partial)
- ❌ Group mapping wizard (defer to post-MVP)
- ❌ Restore preview diff (defer to post-MVP)
- ⚠️ Scope tags restore (capture done, restore deferred)
---
### Full Implementation
**Total**: ~64 tasks
**Estimated**: 30-40 hours
**Breakdown**:
- Phase 1: 17 tasks (2-3h)
- Phase 2: 18 tasks (4-6h)
- Phase 3: 8 tasks (4-5h)
- Phase 4: 5 tasks (3-4h)
- Phase 5: 22 tasks (6-8h)
- Phase 6: 7 tasks (2-3h)
- Phase 7: 8 tasks (2-3h)
- Phase 8: 10 tasks (3-4h)
- Phase 9: 11 tasks (2-3h)
- Phase 10: 10 tasks (1-2h)
---
## Parallel Development Opportunities
### Track 1: Backend Services (Dev A)
- Phase 1: Database setup (17 tasks)
- Phase 2: Graph API services (18 tasks)
- Phase 3: Capture services (8 tasks)
- Phase 5: Restore service (22 tasks)
**Total**: ~16-22 hours
---
### Track 2: Frontend/UI (Dev B)
- Phase 4: Policy Version view (5 tasks)
- Phase 5: Group mapping wizard (subset of Phase 5, ~10 tasks)
- Phase 6: Restore preview diff (7 tasks)
- Phase 8: UI polish (subset, ~5 tasks)
**Total**: ~12-16 hours
---
### Track 3: Testing & QA (Dev C or shared)
- Phase 2: Unit tests for services (subset)
- Phase 3: Feature tests for backup (subset)
- Phase 4: Feature tests for policy version view (subset)
- Phase 9: Manual QA + browser tests (11 tasks)
**Total**: ~8-12 hours
**Note**: Testing can be done incrementally as phases complete.
---
## Dependencies Graph
```
Phase 1 (Setup)
Phase 2 (Graph Services)
Phase 3 (Capture) → Phase 4 (Policy Version view)
↓ ↓
Phase 5 (Restore) ←------+
Phase 6 (Preview Diff)
Phase 7 (Scope Tags)
Phase 8 (Polish)
Phase 9 (Testing & QA)
Phase 10 (Deployment)
```
**Parallel Phases**: Phase 3 and Phase 4 can run in parallel after Phase 2.
---
## Risk Mitigation Tasks
### Risk: Graph API Rate Limiting
- **Task 2.11**: Add retry logic with exponential backoff
- **Task 5.11**: Add 100ms delay between POSTs
- **Task 8.3**: Batch group resolution (max 100 per request)
### Risk: Large Group Counts (500+)
- **Task 5.4**: Implement searchable dropdown with debounce
- **Task 5.6**: Cache target tenant groups
- **Task 8.4**: Pre-warm cache on wizard mount
### Risk: Assignment Restore Failures
- **Task 5.12**: Fail-soft per-assignment error handling
- **Task 5.13**: Store outcomes in RestoreRun results
- **Task 5.14**: Audit log all outcomes
---
## Next Actions
1. **Review**: Team review of tasks.md, adjust estimates if needed
2. **Assign**: Assign tasks to developers (Track 1/2/3)
3. **Start**: Begin with Phase 1 (Setup & Database)
4. **Track Progress**: Update task status as work progresses
5. **Iterate**: Adjust plan based on discoveries during implementation
---
**Status**: Task Breakdown Complete
**Ready for Implementation**: ✅
**Estimated Duration**: 30-40 hours (MVP: 16-22 hours)

View File

@ -0,0 +1,228 @@
# Feature 005: Policy Lifecycle Management
## Overview
Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling.
## Problem Statement
Currently, when a policy is deleted in Intune:
- ❌ Policy remains in TenantAtlas database indefinitely
- ❌ No indication that policy no longer exists in Intune
- ❌ Backup Items reference "ghost" policies
- ❌ Users cannot distinguish between active and deleted policies
**Discovered during**: Feature 004 manual testing (user deleted policy in Intune)
## Goals
- **Primary**: Implement soft delete for policies removed from Intune
- **Secondary**: Show clear UI indicators for deleted policies
- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions
## Scope
- **Policy Sync**: Detect missing policies during `SyncPoliciesJob`
- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern)
- **UI**: Badge indicators, filters, restore capability
- **Audit**: Log when policies are soft-deleted and restored
---
## User Stories
### User Story 1 - Automatic Soft Delete on Sync
**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state.
**Acceptance Criteria:**
1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123",
**When** the next policy sync runs and "abc-123" is NOT returned by Graph API,
**Then** the policy is soft-deleted (sets `deleted_at = now()`)
2. **Given** a soft-deleted policy,
**When** it re-appears in Intune (same `external_id`),
**Then** the policy is automatically restored (`deleted_at = null`)
3. **Given** multiple policies are deleted in Intune,
**When** sync runs,
**Then** all missing policies are soft-deleted in a single transaction
---
### User Story 2 - UI Indicators for Deleted Policies
**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status.
**Acceptance Criteria:**
1. **Given** I view a Backup Item referencing a deleted policy,
**When** I see the policy name,
**Then** it shows a red "Deleted" badge next to the name
2. **Given** I view the Policies list,
**When** I enable the "Show Deleted" filter,
**Then** deleted policies appear with:
- Red "Deleted" badge
- Deleted date in "Last Synced" column
- Grayed-out appearance
3. **Given** a policy was deleted,
**When** I view the Policy detail page,
**Then** I see:
- Warning banner: "This policy was deleted from Intune on {date}"
- All data remains readable (versions, snapshots, metadata)
---
### User Story 3 - Restore Workflow
**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations.
**Acceptance Criteria:**
1. **Given** I view a deleted policy's detail page,
**When** I click the "Restore to Intune" action,
**Then** the restore wizard opens pre-filled with the latest policy snapshot
2. **Given** a policy is successfully restored to Intune,
**When** the next sync runs,
**Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`)
---
## Functional Requirements
### Data Model
**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern:
```php
Schema::table('policies', function (Blueprint $table) {
$table->softDeletes(); // deleted_at
$table->string('deleted_by')->nullable(); // admin email who triggered deletion
});
```
**FR-005.2**: Policy model MUST use `SoftDeletes` trait:
```php
use Illuminate\Database\Eloquent\SoftDeletes;
class Policy extends Model {
use SoftDeletes;
}
```
### Policy Sync Behavior
**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies:
- Collect all `external_id` values returned by Graph API
- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)`
- Soft delete missing policies: `each(fn($p) => $p->delete())`
**FR-005.4**: System MUST restore policies that re-appear:
- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()`
- If soft-deleted: call `$policy->restore()`
- Update `last_synced_at` timestamp
**FR-005.5**: System MUST log audit entries:
- `policy.deleted` (when soft-deleted during sync)
- `policy.restored` (when re-appears in Intune)
### UI Display
**FR-005.6**: PolicyResource table MUST:
- Default query: exclude soft-deleted policies
- Add filter "Show Deleted" (includes `withTrashed()` in query)
- Show "Deleted" badge for soft-deleted policies
**FR-005.7**: BackupItemsRelationManager MUST:
- Show "Deleted" badge when `policy->trashed()` returns true
- Allow viewing deleted policy details (read-only)
**FR-005.8**: Policy detail view MUST:
- Show warning banner when policy is soft-deleted
- Display deletion date and reason (if available)
- Disable edit actions (policy no longer exists in Intune)
---
## Non-Functional Requirements
**NFR-005.1**: Soft delete MUST NOT break existing features:
- Backup Items keep valid foreign keys
- Policy Versions remain accessible
- Restore functionality works for deleted policies
**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries:
- Use single `whereNotIn()` query to find missing policies
- Batch soft-delete operation
**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging)
---
## Implementation Plan
### Phase 1: Data Model (30 min)
1. Create migration for `policies` soft delete columns
2. Add `SoftDeletes` trait to Policy model
3. Run migration on dev environment
### Phase 2: Sync Logic (1 hour)
1. Update `PolicySyncService::syncPolicies()`
- Track current external IDs from Graph
- Soft delete missing policies
- Restore re-appeared policies
2. Add audit logging
3. Test with manual deletion in Intune
### Phase 3: UI Indicators (1.5 hours)
1. Update `PolicyResource`:
- Add "Show Deleted" filter
- Add "Deleted" badge column
- Modify query to exclude deleted by default
2. Update `BackupItemsRelationManager`:
- Show "Deleted" badge for `policy->trashed()`
3. Update Policy detail view:
- Warning banner for deleted policies
- Disable edit actions
### Phase 4: Testing (1 hour)
1. Unit tests:
- Test soft delete on sync
- Test restore on re-appearance
2. Feature tests:
- E2E sync with deleted policies
- UI filter behavior
3. Manual QA:
- Delete policy in Intune → sync → verify soft delete
- Re-create policy → sync → verify restore
**Total Estimated Duration**: ~4-5 hours
---
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid |
| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies |
| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in |
---
## Success Criteria
1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle
2. ✅ Re-appearing policies are automatically restored
3. ✅ UI clearly indicates deleted status
4. ✅ Backup Items and Versions remain accessible for deleted policies
5. ✅ No breaking changes to existing features
---
## Related Features
- Feature 004: Assignments & Scope Tags (discovered this issue during testing)
- Feature 001: Backup/Restore (must work with deleted policies)
---
**Status**: Planned (Post-Feature 004)
**Priority**: P2 (Quality of Life improvement)
**Created**: 2025-12-22
**Author**: AI + Ahmed
**Next Steps**: Implement after Feature 004 Phase 3 testing complete

View File

@ -0,0 +1,135 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\BackupService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::create([
'tenant_id' => 'tenant-123',
'name' => 'Test Tenant',
]);
$this->tenant->makeCurrent();
$this->user = User::factory()->create();
$this->actingAs($this->user);
$this->policy = Policy::create([
'tenant_id' => $this->tenant->id,
'external_id' => 'policy-456',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Test Policy',
'platform' => 'windows',
]);
$this->backupSet = BackupSet::create([
'tenant_id' => $this->tenant->id,
'name' => 'Test Backup Set',
'status' => 'completed',
'created_by' => $this->user->email,
]);
});
it('excludes soft-deleted items when listing available policies to add', function () {
// Create a backup item
$backupItem = BackupItem::create([
'tenant_id' => $this->tenant->id,
'backup_set_id' => $this->backupSet->id,
'policy_id' => $this->policy->id,
'policy_identifier' => $this->policy->external_id,
'policy_type' => $this->policy->policy_type,
'platform' => $this->policy->platform,
'payload' => ['test' => 'data'],
'captured_at' => now(),
]);
// Get available policies (should be empty since policy is already in backup)
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->toContain($this->policy->id);
// Soft-delete the backup item
$backupItem->delete();
// Verify it's soft-deleted
expect($this->backupSet->items()->count())->toBe(0);
expect($this->backupSet->items()->withTrashed()->count())->toBe(1);
// Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them)
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->not->toContain($this->policy->id)
->and($existingPolicyIds)->toHaveCount(0);
});
it('prevents re-adding soft-deleted policies via BackupService', function () {
// Create initial backup item
$backupItem = BackupItem::create([
'tenant_id' => $this->tenant->id,
'backup_set_id' => $this->backupSet->id,
'policy_id' => $this->policy->id,
'policy_identifier' => $this->policy->external_id,
'policy_type' => $this->policy->policy_type,
'platform' => $this->policy->platform,
'payload' => ['test' => 'data'],
'captured_at' => now(),
]);
// Soft-delete it
$backupItem->delete();
// Try to add the same policy again via BackupService
$service = app(BackupService::class);
$result = $service->addPoliciesToSet(
tenant: $this->tenant,
backupSet: $this->backupSet->refresh(),
policyIds: [$this->policy->id],
actorEmail: $this->user->email,
actorName: $this->user->name,
);
// Should restore the soft-deleted item, not create a new one
expect($this->backupSet->items()->count())->toBe(1)
->and($this->backupSet->items()->withTrashed()->count())->toBe(1)
->and($result->item_count)->toBe(1)
->and($backupItem->fresh()->deleted_at)->toBeNull(); // Item should be restored
});
it('allows adding different policy after one was soft-deleted', function () {
// Create initial backup item
$backupItem = BackupItem::create([
'tenant_id' => $this->tenant->id,
'backup_set_id' => $this->backupSet->id,
'policy_id' => $this->policy->id,
'policy_identifier' => $this->policy->external_id,
'policy_type' => $this->policy->policy_type,
'platform' => $this->policy->platform,
'payload' => ['test' => 'data'],
'captured_at' => now(),
]);
// Soft-delete it
$backupItem->delete();
// Create a different policy
$otherPolicy = Policy::create([
'tenant_id' => $this->tenant->id,
'external_id' => 'policy-789',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Other Policy',
'platform' => 'windows',
]);
// Check available policies - should include the new one but not the deleted one
$existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->toContain($this->policy->id)
->and($existingPolicyIds)->not->toContain($otherPolicy->id);
});

View File

@ -0,0 +1,264 @@
<?php
use App\Models\BackupItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['status' => 'active']);
$this->policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id,
'external_id' => 'test-policy-123',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10',
'display_name' => 'Test Policy',
]);
$this->snapshotPayload = [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'id' => 'test-policy-123',
'name' => 'Test Policy',
'description' => 'Test Description',
'platforms' => 'windows10',
'technologies' => 'mdm',
'settings' => [],
];
$this->assignmentsPayload = [
[
'id' => 'assignment-1',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
],
],
[
'id' => 'assignment-2',
'target' => [
'@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget',
],
],
];
$this->resolvedAssignments = [
[
'id' => 'assignment-1',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
'group_name' => 'Test Group',
],
],
[
'id' => 'assignment-2',
'target' => [
'@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget',
],
],
];
// Mock PolicySnapshotService
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->andReturn([
'payload' => $this->snapshotPayload,
'metadata' => ['fetched_at' => now()->toISOString()],
'warnings' => [],
]);
});
// Mock AssignmentFetcher
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->andReturn($this->assignmentsPayload);
});
// Mock GroupResolver
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturn([
'group-123' => [
'id' => 'group-123',
'displayName' => 'Test Group',
'orphaned' => false,
],
]);
});
});
it('creates backup with includeAssignments=true and both BackupItem and PolicyVersion have assignments', function () {
$backupService = app(BackupService::class);
$backupSet = $backupService->createBackupSet(
tenant: $this->tenant,
policyIds: [$this->policy->id],
actorEmail: 'test@example.com',
actorName: 'Test User',
name: 'Test Backup With Assignments',
includeAssignments: true,
);
expect($backupSet)->not->toBeNull();
expect($backupSet->items)->toHaveCount(1);
$backupItem = $backupSet->items->first();
expect($backupItem->assignments)->not->toBeNull();
expect($backupItem->assignments)->toBeArray();
expect($backupItem->assignments)->toHaveCount(2);
expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123');
// CRITICAL: PolicyVersion must also have assignments (domain consistency)
expect($backupItem->policy_version_id)->not->toBeNull();
$version = PolicyVersion::find($backupItem->policy_version_id);
expect($version)->not->toBeNull();
expect($version->assignments)->not->toBeNull();
expect($version->assignments)->toBeArray();
expect($version->assignments)->toHaveCount(2);
expect($version->assignments[0]['target']['groupId'])->toBe('group-123');
// Verify assignments match between BackupItem and PolicyVersion
expect($backupItem->assignments)->toEqual($version->assignments);
});
it('creates backup with includeAssignments=false and both BackupItem and PolicyVersion have no assignments', function () {
$backupService = app(BackupService::class);
$backupSet = $backupService->createBackupSet(
tenant: $this->tenant,
policyIds: [$this->policy->id],
actorEmail: 'test@example.com',
actorName: 'Test User',
name: 'Test Backup Without Assignments',
includeAssignments: false,
);
expect($backupSet)->not->toBeNull();
expect($backupSet->items)->toHaveCount(1);
$backupItem = $backupSet->items->first();
expect($backupItem->assignments)->toBeNull();
// CRITICAL: PolicyVersion must also have no assignments (domain consistency)
expect($backupItem->policy_version_id)->not->toBeNull();
$version = PolicyVersion::find($backupItem->policy_version_id);
expect($version)->not->toBeNull();
expect($version->assignments)->toBeNull();
});
it('backfills existing PolicyVersion without assignments when creating backup with includeAssignments=true', function () {
// Create an existing PolicyVersion without assignments (simulate old backup)
$existingVersion = PolicyVersion::create([
'policy_id' => $this->policy->id,
'tenant_id' => $this->tenant->id,
'version_number' => 1,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10',
'snapshot' => $this->snapshotPayload,
'assignments' => null, // NO ASSIGNMENTS
'scope_tags' => null,
'assignments_hash' => null,
'scope_tags_hash' => null,
'created_by' => 'legacy-system@example.com',
]);
expect($existingVersion->assignments)->toBeNull();
expect($existingVersion->assignments_hash)->toBeNull();
$backupService = app(BackupService::class);
// Create new backup with includeAssignments=true
// Orchestrator should detect existing version and backfill it
$backupSet = $backupService->createBackupSet(
tenant: $this->tenant,
policyIds: [$this->policy->id],
actorEmail: 'test@example.com',
actorName: 'Test User',
name: 'Test Backup Backfills Version',
includeAssignments: true,
);
expect($backupSet)->not->toBeNull();
expect($backupSet->items)->toHaveCount(1);
$backupItem = $backupSet->items->first();
// BackupItem should have assignments
expect($backupItem->assignments)->not->toBeNull();
expect($backupItem->assignments)->toHaveCount(2);
// CRITICAL: Existing PolicyVersion should now be backfilled (idempotent)
// The orchestrator should have detected same payload_hash and enriched it
$existingVersion->refresh();
expect($existingVersion->assignments)->not->toBeNull();
expect($existingVersion->assignments)->toHaveCount(2);
expect($existingVersion->assignments_hash)->not->toBeNull();
expect($existingVersion->assignments[0]['target']['groupId'])->toBe('group-123');
// BackupItem should reference the backfilled version
expect($backupItem->policy_version_id)->toBe($existingVersion->id);
});
it('does not overwrite existing PolicyVersion assignments when they already exist (idempotent)', function () {
// Create an existing PolicyVersion WITH assignments
$existingAssignments = [
[
'id' => 'old-assignment',
'target' => ['@odata.type' => '#microsoft.graph.allLicensedUsersAssignmentTarget'],
],
];
$existingVersion = PolicyVersion::create([
'policy_id' => $this->policy->id,
'tenant_id' => $this->tenant->id,
'version_number' => 1,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows10',
'snapshot' => $this->snapshotPayload,
'assignments' => $existingAssignments,
'scope_tags' => null,
'assignments_hash' => hash('sha256', json_encode($existingAssignments)),
'scope_tags_hash' => null,
'created_by' => 'previous-backup@example.com',
]);
$backupService = app(BackupService::class);
// Create new backup - orchestrator should NOT overwrite existing assignments
$backupSet = $backupService->createBackupSet(
tenant: $this->tenant,
policyIds: [$this->policy->id],
actorEmail: 'test@example.com',
actorName: 'Test User',
name: 'Test Backup Preserves Existing',
includeAssignments: true,
);
expect($backupSet)->not->toBeNull();
expect($backupSet->items)->toHaveCount(1);
$backupItem = $backupSet->items->first();
// BackupItem should have NEW assignments (from current fetch)
expect($backupItem->assignments)->not->toBeNull();
expect($backupItem->assignments)->toHaveCount(2);
expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123');
// CRITICAL: Existing PolicyVersion should NOT be modified (idempotent)
$existingVersion->refresh();
expect($existingVersion->assignments)->toEqual($existingAssignments);
expect($existingVersion->assignments)->toHaveCount(1);
expect($existingVersion->assignments[0]['id'])->toBe('old-assignment');
// BackupItem should reference the existing version (reused)
expect($backupItem->policy_version_id)->toBe($existingVersion->id);
});

View File

@ -2,16 +2,43 @@
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
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\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('backup creation captures snapshots and audit log', function () { test('backup creation captures snapshots and audit log', function () {
// Mock PolicySnapshotService
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->twice() // Called once for each policy
->andReturnUsing(function ($tenant, $policy) {
return [
'payload' => [
'id' => $policy->external_id,
'name' => $policy->display_name,
'roleScopeTagIds' => ['0'],
],
'metadata' => [],
'warnings' => [],
];
});
});
// Mock ScopeTagResolver
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->andReturn([['id' => '0', 'displayName' => 'Default']]);
});
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{ {
public function listPolicies(string $policyType, array $options = []): GraphResponse public function listPolicies(string $policyType, array $options = []): GraphResponse
@ -59,6 +86,7 @@ public function request(string $method, string $path, array $options = []): Grap
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'platform' => 'windows', 'platform' => 'windows',
'last_synced_at' => now(),
]); ]);
$policyB = Policy::create([ $policyB = Policy::create([
@ -67,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap
'policy_type' => 'deviceCompliancePolicy', 'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Policy B', 'display_name' => 'Policy B',
'platform' => 'windows', 'platform' => 'windows',
'last_synced_at' => now(),
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
@ -81,13 +110,23 @@ public function request(string $method, string $path, array $options = []): Grap
'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
])->callTableAction('addPolicies', data: [ ])->callTableAction('addPolicies', data: [
'policy_ids' => [$policyA->id, $policyB->id], 'policy_ids' => [$policyA->id, $policyB->id],
'include_assignments' => false,
'include_scope_tags' => true,
]); ]);
$backupSet->refresh(); $backupSet->refresh();
expect($backupSet->item_count)->toBe(2); expect($backupSet->item_count)->toBe(2);
expect($backupSet->items)->toHaveCount(2); expect($backupSet->items)->toHaveCount(2);
expect($backupSet->items->first()->payload['policyId'])->toBe('policy-1'); expect($backupSet->items->first()->payload['id'])->toBe('policy-1');
$firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id);
expect($firstVersion)->not->toBeNull();
expect($firstVersion->scope_tags)->toBe([
'ids' => ['0'],
'names' => ['Default'],
]);
expect($firstVersion->assignments)->toBeNull();
$this->assertDatabaseHas('audit_logs', [ $this->assertDatabaseHas('audit_logs', [
'action' => 'backup.created', 'action' => 'backup.created',

View File

@ -0,0 +1,64 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
it('captures a policy snapshot with scope tags when requested', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$policy = Policy::factory()->for($tenant)->create([
'external_id' => 'policy-123',
]);
$user = User::factory()->create();
$this->actingAs($user);
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => $policy->external_id,
'name' => $policy->display_name,
'roleScopeTagIds' => ['0'],
],
]);
});
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')->never();
});
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolve')
->once()
->andReturn([
['id' => '0', 'displayName' => 'Default'],
]);
});
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->callAction('capture_snapshot', data: [
'include_assignments' => false,
'include_scope_tags' => true,
]);
$version = $policy->versions()->first();
expect($version)->not->toBeNull();
expect($version->assignments)->toBeNull();
expect($version->scope_tags)->toBe([
'ids' => ['0'],
'names' => ['Default'],
]);
});

View File

@ -21,6 +21,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'platform' => 'windows', 'platform' => 'windows',
'last_synced_at' => now(),
]); ]);
$otherTenant = Tenant::create([ $otherTenant = Tenant::create([
@ -35,6 +36,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy B', 'display_name' => 'Policy B',
'platform' => 'windows', 'platform' => 'windows',
'last_synced_at' => now(),
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();

View File

@ -169,9 +169,28 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->requestCalls[0]['method'])->toBe('POST'); expect($client->requestCalls[0]['method'])->toBe('POST');
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings'); expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings');
$results = $run->results;
$results[0]['assignment_summary'] = [
'success' => 0,
'failed' => 1,
'skipped' => 0,
];
$results[0]['assignment_outcomes'] = [[
'status' => 'failed',
'group_id' => 'group-1',
'mapped_group_id' => 'group-2',
'reason' => 'Graph create failed',
'graph_error_message' => 'Bad request',
]];
$run->update(['results' => $results]);
$response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); $response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run]));
$response->assertOk(); $response->assertOk();
$response->assertSee('Graph bulk apply failed'); $response->assertSee('Graph bulk apply failed');
$response->assertSee('Setting missing'); $response->assertSee('Setting missing');
$response->assertSee('req-setting-404'); $response->assertSee('req-setting-404');
$response->assertSee('Assignments: 0 success');
$response->assertSee('Assignment details');
$response->assertSee('Graph create failed');
}); });

View File

@ -83,7 +83,7 @@ public function request(string $method, string $path, array $options = []): Grap
$this->assertDatabaseHas('tenant_permissions', [ $this->assertDatabaseHas('tenant_permissions', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'status' => 'ok', 'status' => 'granted',
]); ]);
}); });

View File

@ -0,0 +1,90 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id,
]);
$this->user = User::factory()->create();
});
it('displays policy version page', function () {
$version = PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 1,
]);
$this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}");
$response->assertOk();
});
it('displays assignments widget when version has assignments', function () {
$version = PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 1,
'assignments' => [
[
'id' => 'assignment-1',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
'assignment_filter_name' => 'Targeted Devices',
'deviceAndAppManagementAssignmentFilterId' => 'filter-1',
'deviceAndAppManagementAssignmentFilterType' => 'include',
],
],
[
'id' => 'assignment-2',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget',
'groupId' => 'group-456',
'deviceAndAppManagementAssignmentFilterId' => null,
'deviceAndAppManagementAssignmentFilterType' => 'none',
],
],
],
'scope_tags' => [
'ids' => ['0'],
'names' => ['Default'],
],
]);
$this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}");
$response->assertOk();
$response->assertSeeLivewire('policy-version-assignments-widget');
$response->assertSee('2 assignment(s)');
$response->assertSee('Include group');
$response->assertSee('Exclude group');
$response->assertSee('Filter (include): Targeted Devices');
});
it('displays empty state when version has no assignments', function () {
$version = PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 1,
'assignments' => null,
]);
$this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}");
$response->assertOk();
$response->assertSee('Assignments were not captured for this version');
});

View File

@ -0,0 +1,249 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class RestoreAssignmentGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{method:string,path:string,payload:array|null}>
*/
public array $requestCalls = [];
/**
* @param array<int, GraphResponse> $requestResponses
*/
public function __construct(
private readonly GraphResponse $applyPolicyResponse,
private array $requestResponses = [],
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return $this->applyPolicyResponse;
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requestCalls[] = [
'method' => strtoupper($method),
'path' => $path,
'payload' => $options['json'] ?? null,
];
return array_shift($this->requestResponses) ?? new GraphResponse(true, []);
}
}
test('restore applies assignments with mapped groups', 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' => 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' => ['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',
'group_display_name' => 'Source One',
],
],
[
'id' => 'assignment-2',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-2',
],
],
],
]);
$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,
groupMapping: [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(2);
expect($summary['failed'])->toBe(0);
$postCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST')
->values();
expect($postCalls)->toHaveCount(1);
expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign');
$payloadAssignments = $postCalls[0]['payload']['assignments'] ?? [];
$groupIds = collect($payloadAssignments)->pluck('target.groupId')->all();
expect($groupIds)->toBe(['target-group-1', 'target-group-2']);
expect($payloadAssignments[0])->not->toHaveKey('id');
});
test('restore handles assignment failures gracefully', function () {
$applyResponse = new GraphResponse(true, []);
$requestResponses = [
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
['code' => 'BadRequest', 'message' => 'Bad request'],
], [], [
'error_code' => 'BadRequest',
'error_message' => 'Bad request',
]), // assign action fails
];
$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' => 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' => ['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',
],
],
[
'id' => 'assignment-2',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-2',
],
],
],
]);
$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,
groupMapping: [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(0);
expect($summary['failed'])->toBe(2);
expect($run->results[0]['status'])->toBe('partial');
});

View File

@ -0,0 +1,173 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GroupResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore wizard shows group mapping for unresolved groups', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source Group',
],
'intent' => 'apply',
]],
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(fn (string $id) => [$id => [
'id' => $id,
'displayName' => null,
'orphaned' => true,
]])
->all();
});
});
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
'backup_item_ids' => [$backupItem->id],
])
->assertFormFieldVisible('group_mapping.source-group-1');
});
test('restore wizard persists group mapping selections', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source Group',
],
'intent' => 'apply',
]],
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(function (string $id) {
$resolved = $id === 'target-group-1';
return [$id => [
'id' => $id,
'displayName' => $resolved ? 'Target Group' : null,
'orphaned' => ! $resolved,
]];
})
->all();
});
});
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
'backup_item_ids' => [$backupItem->id],
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
'is_dry_run' => true,
])
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::first();
expect($run)->not->toBeNull();
expect($run->group_mapping)->toBe([
'source-group-1' => 'target-group-1',
]);
$this->assertDatabaseHas('audit_logs', [
'tenant_id' => $tenant->id,
'action' => 'restore.group_mapping.applied',
]);
});

View File

@ -0,0 +1,200 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\VersionService;
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->policy = Policy::factory()->create([
'tenant_id' => $this->tenant->id,
'external_id' => 'test-policy-id',
]);
$this->mock(ScopeTagResolver::class, function ($mock) {
$mock->shouldReceive('resolve')
->andReturn([
['id' => '0', 'displayName' => 'Default'],
]);
});
});
it('captures policy version with assignments from graph', function () {
// Mock dependencies
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => 'test-policy-id',
'name' => 'Test Policy',
'settings' => [],
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
[
'id' => 'assignment-1',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
'deviceAndAppManagementAssignmentFilterId' => 'filter-123',
'deviceAndAppManagementAssignmentFilterType' => 'include',
],
],
]);
});
$this->mock(GroupResolver::class, function ($mock) {
$mock->shouldReceive('resolveGroupIds')
->once()
->andReturn([
'group-123' => [
'id' => 'group-123',
'displayName' => 'Test Group',
'orphaned' => false,
],
]);
});
$this->mock(AssignmentFilterResolver::class, function ($mock) {
$mock->shouldReceive('resolve')
->once()
->andReturn([
['id' => 'filter-123', 'displayName' => 'Targeted Devices'],
]);
});
$versionService = app(VersionService::class);
$version = $versionService->captureFromGraph(
$this->tenant,
$this->policy,
'test@example.com'
);
expect($version)->not->toBeNull()
->and($version->assignments)->not->toBeNull()
->and($version->assignments)->toHaveCount(1)
->and($version->assignments[0]['target']['groupId'])->toBe('group-123')
->and($version->assignments_hash)->not->toBeNull()
->and($version->metadata['assignments_count'])->toBe(1)
->and($version->metadata['has_orphaned_assignments'])->toBeFalse()
->and($version->scope_tags)->toBe([
'ids' => ['0'],
'names' => ['Default'],
]);
expect($version->assignments[0]['target']['group_display_name'])->toBe('Test Group');
expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
});
it('captures policy version without assignments when none exist', function () {
// Mock dependencies
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => 'test-policy-id',
'name' => 'Test Policy',
'settings' => [],
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([]);
});
$versionService = app(VersionService::class);
$version = $versionService->captureFromGraph(
$this->tenant,
$this->policy,
'test@example.com'
);
expect($version)->not->toBeNull()
->and($version->assignments)->toBeNull()
->and($version->assignments_hash)->toBeNull();
});
it('handles assignment fetch failure gracefully', function () {
// Mock dependencies
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => 'test-policy-id',
'name' => 'Test Policy',
'settings' => [],
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andThrow(new \Exception('Graph API error'));
});
$versionService = app(VersionService::class);
$version = $versionService->captureFromGraph(
$this->tenant,
$this->policy,
'test@example.com'
);
expect($version)->not->toBeNull()
->and($version->assignments)->toBeNull()
->and($version->metadata['assignments_fetch_failed'])->toBeTrue()
->and($version->metadata['assignments_fetch_error'])->toBe('Graph API error');
});
it('calculates correct hash for assignments', function () {
$assignments = [
['id' => '1', 'target' => ['groupId' => 'group-1']],
['id' => '2', 'target' => ['groupId' => 'group-2']],
];
$version = $this->policy->versions()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 1,
'policy_type' => 'deviceManagementConfigurationPolicy',
'snapshot' => ['test' => 'data'],
'assignments' => $assignments,
'assignments_hash' => hash('sha256', json_encode($assignments)),
'captured_at' => now(),
]);
$expectedHash = hash('sha256', json_encode($assignments));
expect($version->assignments_hash)->toBe($expectedHash);
// Verify same assignments produce same hash
$version2 = $this->policy->versions()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 2,
'policy_type' => 'deviceManagementConfigurationPolicy',
'snapshot' => ['test' => 'data'],
'assignments' => $assignments,
'assignments_hash' => hash('sha256', json_encode($assignments)),
'captured_at' => now(),
]);
expect($version2->assignments_hash)->toBe($version->assignments_hash);
});

View File

@ -0,0 +1,163 @@
<?php
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GraphException;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->fetcher = new AssignmentFetcher($this->graphClient);
});
test('primary endpoint success', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
];
$response = new GraphResponse(
success: true,
data: ['value' => $assignments]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
'tenant' => $tenantId,
])
->andReturn($response);
$result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe($assignments);
});
test('fallback on empty response', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
];
// Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
'tenant' => $tenantId,
])
->andReturn($primaryResponse);
// Fallback returns assignments
$fallbackResponse = new GraphResponse(
success: true,
data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', '/deviceManagement/configurationPolicies', [
'tenant' => $tenantId,
'query' => [
'$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'",
],
])
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe($assignments);
});
test('fail soft on error', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$this->graphClient
->shouldReceive('request')
->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
$result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe([]);
});
test('returns empty array when both endpoints return empty', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
// Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
->andReturn($primaryResponse);
// Fallback returns empty
$fallbackResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any())
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe([]);
});
test('fallback handles missing assignments key', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
// Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($primaryResponse);
// Fallback returns policy without assignments key
$fallbackResponse = new GraphResponse(
success: true,
data: ['value' => [['id' => $policyId]]]
);
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe([]);
});

View File

@ -0,0 +1,65 @@
<?php
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->resolver = new AssignmentFilterResolver($this->graphClient);
});
test('resolves assignment filters by id', function () {
$filters = [
['id' => 'filter-1', 'displayName' => 'Targeted Devices'],
['id' => 'filter-2', 'displayName' => 'Excluded Devices'],
];
$response = new GraphResponse(
success: true,
data: ['value' => $filters]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', '/deviceManagement/assignmentFilters', [
'query' => [
'$select' => 'id,displayName',
],
])
->andReturn($response);
$result = $this->resolver->resolve(['filter-1']);
expect($result)->toHaveCount(1)
->and($result[0]['id'])->toBe('filter-1')
->and($result[0]['displayName'])->toBe('Targeted Devices');
});
test('uses cache for repeated lookups', function () {
$filters = [
['id' => 'filter-1', 'displayName' => 'Targeted Devices'],
];
$response = new GraphResponse(
success: true,
data: ['value' => $filters]
);
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($response);
$result1 = $this->resolver->resolve(['filter-1']);
$result2 = $this->resolver->resolve(['filter-1']);
expect($result1)->toBe($result2);
});

View File

@ -0,0 +1,157 @@
<?php
use App\Models\BackupItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('assignments cast works', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => [
['id' => 'abc-123', 'target' => ['groupId' => 'group-1']],
],
]);
expect($backupItem->assignments)->toBeArray()
->and($backupItem->assignments)->toHaveCount(1);
});
test('getAssignmentCountAttribute returns correct count', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => [
['id' => 'abc-123', 'target' => ['groupId' => 'group-1']],
['id' => 'def-456', 'target' => ['groupId' => 'group-2']],
],
]);
expect($backupItem->assignment_count)->toBe(2);
});
test('getAssignmentCountAttribute returns zero for null assignments', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => null,
]);
expect($backupItem->assignment_count)->toBe(0);
});
test('hasAssignments returns true when assignments exist', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => [
['id' => 'abc-123', 'target' => ['groupId' => 'group-1']],
],
]);
expect($backupItem->hasAssignments())->toBeTrue();
});
test('hasAssignments returns false when assignments are null', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => null,
]);
expect($backupItem->hasAssignments())->toBeFalse();
});
test('getGroupIdsAttribute extracts unique group IDs', function () {
$backupItem = BackupItem::factory()->create([
'assignments' => [
['id' => 'abc-123', 'target' => ['groupId' => 'group-1']],
['id' => 'def-456', 'target' => ['groupId' => 'group-2']],
['id' => 'ghi-789', 'target' => ['groupId' => 'group-1']], // duplicate
],
]);
expect($backupItem->group_ids)->toHaveCount(2)
->and($backupItem->group_ids)->toContain('group-1', 'group-2');
});
test('getScopeTagIdsAttribute returns scope tag IDs from metadata', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [
'scope_tag_ids' => ['0', 'abc-123', 'def-456'],
],
]);
expect($backupItem->scope_tag_ids)->toHaveCount(3)
->and($backupItem->scope_tag_ids)->toContain('0', 'abc-123', 'def-456');
});
test('getScopeTagIdsAttribute returns default when not in metadata', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [],
]);
expect($backupItem->scope_tag_ids)->toBe(['0']);
});
test('getScopeTagNamesAttribute returns scope tag names from metadata', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [
'scope_tag_names' => ['Default', 'HR-Admins', 'Finance'],
],
]);
expect($backupItem->scope_tag_names)->toHaveCount(3)
->and($backupItem->scope_tag_names)->toContain('Default', 'HR-Admins', 'Finance');
});
test('getScopeTagNamesAttribute returns default when not in metadata', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [],
]);
expect($backupItem->scope_tag_names)->toBe(['Default']);
});
test('hasOrphanedAssignments returns true when flag is set', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [
'has_orphaned_assignments' => true,
],
]);
expect($backupItem->hasOrphanedAssignments())->toBeTrue();
});
test('hasOrphanedAssignments returns false when flag is not set', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [],
]);
expect($backupItem->hasOrphanedAssignments())->toBeFalse();
});
test('assignmentsFetchFailed returns true when flag is set', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [
'assignments_fetch_failed' => true,
],
]);
expect($backupItem->assignmentsFetchFailed())->toBeTrue();
});
test('assignmentsFetchFailed returns false when flag is not set', function () {
$backupItem = BackupItem::factory()->create([
'metadata' => [],
]);
expect($backupItem->assignmentsFetchFailed())->toBeFalse();
});
test('scopeWithAssignments filters items with assignments', function () {
BackupItem::factory()->create(['assignments' => null]);
BackupItem::factory()->create(['assignments' => []]);
$withAssignments = BackupItem::factory()->create([
'assignments' => [
['id' => 'abc-123', 'target' => ['groupId' => 'group-1']],
],
]);
$result = BackupItem::withAssignments()->get();
expect($result)->toHaveCount(1)
->and($result->first()->id)->toBe($withAssignments->id);
});

View File

@ -0,0 +1,179 @@
<?php
use App\Services\Graph\GraphException;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->resolver = new GroupResolver($this->graphClient);
});
test('resolves all groups', function () {
$tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2', 'group-3'];
$graphData = [
'value' => [
['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'],
['id' => 'group-3', 'displayName' => 'Contractors'],
],
];
$response = new GraphResponse(
success: true,
data: $graphData
);
$this->graphClient
->shouldReceive('request')
->once()
->with('POST', '/directoryObjects/getByIds', [
'tenant' => $tenantId,
'json' => [
'ids' => $groupIds,
'types' => ['group'],
],
])
->andReturn($response);
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
expect($result)->toHaveKey('group-1')
->and($result['group-1'])->toBe([
'id' => 'group-1',
'displayName' => 'All Users',
'orphaned' => false,
])
->and($result)->toHaveKey('group-2')
->and($result['group-2']['orphaned'])->toBeFalse()
->and($result)->toHaveKey('group-3')
->and($result['group-3']['orphaned'])->toBeFalse();
});
test('handles orphaned ids', function () {
$tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2', 'group-3'];
$graphData = [
'value' => [
['id' => 'group-1', 'displayName' => 'All Users'],
// group-2 and group-3 are missing (deleted)
],
];
$response = new GraphResponse(
success: true,
data: $graphData
);
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($response);
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
expect($result)->toHaveKey('group-1')
->and($result['group-1']['orphaned'])->toBeFalse()
->and($result)->toHaveKey('group-2')
->and($result['group-2'])->toBe([
'id' => 'group-2',
'displayName' => null,
'orphaned' => true,
])
->and($result)->toHaveKey('group-3')
->and($result['group-3']['orphaned'])->toBeTrue();
});
test('caches results', function () {
$tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2'];
$graphData = [
'value' => [
['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'],
],
];
$response = new GraphResponse(
success: true,
data: $graphData
);
// First call - should hit Graph API
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($response);
$result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId);
// Second call - should use cache (no Graph API call)
$result2 = $this->resolver->resolveGroupIds($groupIds, $tenantId);
expect($result1)->toBe($result2)
->and($result1)->toHaveCount(2);
});
test('returns empty array for empty input', function () {
$result = $this->resolver->resolveGroupIds([], 'tenant-123');
expect($result)->toBe([]);
});
test('handles graph exception gracefully', function () {
$tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2'];
$this->graphClient
->shouldReceive('request')
->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
// All groups should be marked as orphaned on failure
expect($result)->toHaveKey('group-1')
->and($result['group-1']['orphaned'])->toBeTrue()
->and($result['group-1']['displayName'])->toBeNull()
->and($result)->toHaveKey('group-2')
->and($result['group-2']['orphaned'])->toBeTrue();
});
test('cache key is consistent regardless of array order', function () {
$tenantId = 'tenant-123';
$groupIds1 = ['group-1', 'group-2', 'group-3'];
$groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order
$graphData = [
'value' => [
['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'],
['id' => 'group-3', 'displayName' => 'Contractors'],
],
];
$response = new GraphResponse(
success: true,
data: $graphData
);
// First call with groupIds1
$this->graphClient
->shouldReceive('request')
->once()
->andReturn($response);
$result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId);
// Second call with groupIds2 (different order) - should use cache
$result2 = $this->resolver->resolveGroupIds($groupIds2, $tenantId);
expect($result1)->toBe($result2);
});

View File

@ -0,0 +1,181 @@
<?php
use App\Models\RestoreRun;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('group_mapping cast works', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
]);
expect($restoreRun->group_mapping)->toBeArray()
->and($restoreRun->group_mapping)->toHaveCount(2);
});
test('hasGroupMapping returns true when mapping exists', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
expect($restoreRun->hasGroupMapping())->toBeTrue();
});
test('hasGroupMapping returns false when mapping is null', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => null,
]);
expect($restoreRun->hasGroupMapping())->toBeFalse();
});
test('getMappedGroupId returns mapped group ID', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
expect($restoreRun->getMappedGroupId('source-group-1'))->toBe('target-group-1');
});
test('getMappedGroupId returns null for unmapped group', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
expect($restoreRun->getMappedGroupId('source-group-2'))->toBeNull();
});
test('isGroupSkipped returns true for skipped group', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'SKIP',
],
]);
expect($restoreRun->isGroupSkipped('source-group-1'))->toBeTrue();
});
test('isGroupSkipped returns false for mapped group', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
expect($restoreRun->isGroupSkipped('source-group-1'))->toBeFalse();
});
test('getUnmappedGroupIds returns groups without mapping', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
$unmapped = $restoreRun->getUnmappedGroupIds(['source-group-1', 'source-group-2', 'source-group-3']);
expect($unmapped)->toHaveCount(2)
->and($unmapped)->toContain('source-group-2', 'source-group-3');
});
test('addGroupMapping adds new mapping', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
$restoreRun->addGroupMapping('source-group-2', 'target-group-2');
expect($restoreRun->group_mapping)->toHaveCount(2)
->and($restoreRun->group_mapping['source-group-2'])->toBe('target-group-2');
});
test('addGroupMapping overwrites existing mapping', function () {
$restoreRun = RestoreRun::factory()->create([
'group_mapping' => [
'source-group-1' => 'target-group-1',
],
]);
$restoreRun->addGroupMapping('source-group-1', 'target-group-new');
expect($restoreRun->group_mapping)->toHaveCount(1)
->and($restoreRun->group_mapping['source-group-1'])->toBe('target-group-new');
});
test('getAssignmentRestoreOutcomes returns outcomes from results', function () {
$restoreRun = RestoreRun::factory()->create([
'results' => [
'assignment_outcomes' => [
['status' => 'success', 'assignment' => []],
['status' => 'failed', 'assignment' => []],
],
],
]);
expect($restoreRun->getAssignmentRestoreOutcomes())->toHaveCount(2);
});
test('getAssignmentRestoreOutcomes returns empty array when not set', function () {
$restoreRun = RestoreRun::factory()->create([
'results' => [],
]);
expect($restoreRun->getAssignmentRestoreOutcomes())->toBeEmpty();
});
test('getSuccessfulAssignmentsCount returns correct count', function () {
$restoreRun = RestoreRun::factory()->create([
'results' => [
'assignment_outcomes' => [
['status' => 'success', 'assignment' => []],
['status' => 'success', 'assignment' => []],
['status' => 'failed', 'assignment' => []],
['status' => 'skipped', 'assignment' => []],
],
],
]);
expect($restoreRun->getSuccessfulAssignmentsCount())->toBe(2);
});
test('getFailedAssignmentsCount returns correct count', function () {
$restoreRun = RestoreRun::factory()->create([
'results' => [
'assignment_outcomes' => [
['status' => 'success', 'assignment' => []],
['status' => 'failed', 'assignment' => []],
['status' => 'failed', 'assignment' => []],
],
],
]);
expect($restoreRun->getFailedAssignmentsCount())->toBe(2);
});
test('getSkippedAssignmentsCount returns correct count', function () {
$restoreRun = RestoreRun::factory()->create([
'results' => [
'assignment_outcomes' => [
['status' => 'success', 'assignment' => []],
['status' => 'skipped', 'assignment' => []],
['status' => 'skipped', 'assignment' => []],
['status' => 'skipped', 'assignment' => []],
],
],
]);
expect($restoreRun->getSkippedAssignmentsCount())->toBe(3);
});

View File

@ -0,0 +1,142 @@
<?php
use App\Models\Tenant;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
});
test('resolves scope tag IDs to objects with id and displayName', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->with('GET', '/deviceManagement/roleScopeTags', Mockery::on(function ($options) use ($tenant) {
return $options['query']['$select'] === 'id,displayName'
&& $options['tenant'] === $tenant->external_id
&& $options['client_id'] === $tenant->app_client_id
&& $options['client_secret'] === $tenant->app_client_secret;
}))
->once()
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
],
]
));
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve(['0', '1', '2'], $tenant);
expect($result)->toBe([
['id' => '0', 'displayName' => 'Default'],
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
]);
});
test('caches scope tag objects for 1 hour', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once() // Only called once due to caching
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
],
]
));
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
// First call - fetches from API
$result1 = $resolver->resolve(['0'], $tenant);
// Second call - should use cache
$result2 = $resolver->resolve(['0'], $tenant);
expect($result1)->toBe([['id' => '0', 'displayName' => 'Default']]);
expect($result2)->toBe([['id' => '0', 'displayName' => 'Default']]);
});
test('returns empty array for empty input', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve([], $tenant);
expect($result)->toBe([]);
});
test('handles 403 forbidden gracefully', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once()
->andReturn(new GraphResponse(
success: false,
status: 403,
data: []
));
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
$result = $resolver->resolve(['0', '1'], $tenant);
// Should return empty array when 403
expect($result)->toBe([]);
});
test('filters returned scope tags to requested IDs', function () {
$tenant = Tenant::factory()->create();
$mockGraphClient = Mockery::mock(MicrosoftGraphClient::class);
$mockGraphClient->shouldReceive('request')
->once()
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
['id' => '1', 'displayName' => 'Verbund-1'],
['id' => '2', 'displayName' => 'Verbund-2'],
],
]
));
$mockLogger = Mockery::mock(GraphLogger::class);
$resolver = new ScopeTagResolver($mockGraphClient, $mockLogger);
// Request only IDs 0 and 2
$result = $resolver->resolve(['0', '2'], $tenant);
expect($result)->toHaveCount(2);
// Note: array_filter preserves keys, so result will be [0 => ..., 2 => ...]
expect($result[0])->toBe(['id' => '0', 'displayName' => 'Default']);
expect($result[2])->toBe(['id' => '2', 'displayName' => 'Verbund-2']);
});

View File

@ -42,14 +42,14 @@ function requiredPermissions(): array
TenantPermission::create([ TenantPermission::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'permission_key' => $permission['key'], 'permission_key' => $permission['key'],
'status' => 'ok', 'status' => 'granted',
]); ]);
} }
$result = app(TenantPermissionService::class)->compare($tenant); $result = app(TenantPermissionService::class)->compare($tenant);
expect($result['overall_status'])->toBe('ok'); expect($result['overall_status'])->toBe('granted');
expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'ok')->count()) expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'granted')->count())
->toBe(count(requiredPermissions())); ->toBe(count(requiredPermissions()));
}); });