feat/005-bulk-operations #5
@ -21,6 +21,8 @@ ## TenantPilot setup
|
||||
- `GRAPH_CLIENT_SECRET`
|
||||
- `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`)
|
||||
- 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):
|
||||
- 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.
|
||||
|
||||
@ -367,6 +367,8 @@ public static function createBackupSet(array $data): BackupSet
|
||||
name: $data['name'] ?? null,
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
includeAssignments: $data['include_assignments'] ?? false,
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ class BackupItemsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
@ -46,13 +47,41 @@ public function table(Table $table): Table
|
||||
->label('Policy ID')
|
||||
->copyable(),
|
||||
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('created_at')->since(),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([
|
||||
Actions\Action::make('addPolicies')
|
||||
->label('Policies hinzufügen')
|
||||
->label('Add Policies')
|
||||
->icon('heroicon-o-plus')
|
||||
->form([
|
||||
Forms\Components\Select::make('policy_ids')
|
||||
@ -70,10 +99,19 @@ public function table(Table $table): Table
|
||||
|
||||
return Policy::query()
|
||||
->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))
|
||||
->orderBy('display_name')
|
||||
->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) {
|
||||
if (empty($data['policy_ids'])) {
|
||||
@ -94,6 +132,8 @@ public function table(Table $table): Table
|
||||
policyIds: $data['policy_ids'],
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
includeAssignments: $data['include_assignments'] ?? false,
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
|
||||
@ -214,6 +214,11 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): 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([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Policy')
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Services\Intune\VersionService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
@ -23,7 +24,17 @@ protected function getActions(): array
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture snapshot now')
|
||||
->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;
|
||||
|
||||
try {
|
||||
@ -38,7 +49,13 @@ protected function getActions(): array
|
||||
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()
|
||||
->title('Snapshot captured successfully.')
|
||||
|
||||
@ -5,10 +5,18 @@
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class ViewPolicyVersion extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PolicyVersionResource::class;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -21,13 +23,16 @@
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
@ -63,6 +68,10 @@ public static function form(Schema $schema): Schema
|
||||
});
|
||||
})
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('backup_item_ids', []);
|
||||
$set('group_mapping', []);
|
||||
})
|
||||
->required(),
|
||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||
->label('Items to restore (optional)')
|
||||
@ -95,7 +104,57 @@ public static function form(Schema $schema): Schema
|
||||
});
|
||||
})
|
||||
->columns(2)
|
||||
->reactive()
|
||||
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||
->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')
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true),
|
||||
@ -407,6 +466,161 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||
actorEmail: auth()->user()?->email,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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('domain')->copyable(),
|
||||
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
|
||||
Infolists\Components\TextEntry::make('status')->badge(),
|
||||
Infolists\Components\TextEntry::make('app_status')->badge(),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->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('created_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_last_checked_at')->label('RBAC last checked')->since(),
|
||||
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
|
||||
@ -281,7 +306,13 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Features')
|
||||
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge(),
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'granted' => 'success',
|
||||
'missing' => 'warning',
|
||||
'error' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
@ -908,7 +939,7 @@ public static function verifyTenant(
|
||||
actorEmail: $user?->email,
|
||||
actorName: $user?->name,
|
||||
status: match ($permissions['overall_status']) {
|
||||
'ok' => 'success',
|
||||
'granted' => 'success',
|
||||
'error' => 'error',
|
||||
default => 'partial',
|
||||
},
|
||||
|
||||
87
app/Jobs/FetchAssignmentsJob.php
Normal file
87
app/Jobs/FetchAssignmentsJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/Jobs/RestoreAssignmentsJob.php
Normal file
84
app/Jobs/RestoreAssignmentsJob.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Livewire/PolicyVersionAssignmentsWidget.php
Normal file
23
app/Livewire/PolicyVersionAssignmentsWidget.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ class BackupItem extends Model
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'metadata' => 'array',
|
||||
'assignments' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
|
||||
@ -36,4 +37,57 @@ public function policy(): BelongsTo
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ class PolicyVersion extends Model
|
||||
protected $casts = [
|
||||
'snapshot' => 'array',
|
||||
'metadata' => 'array',
|
||||
'assignments' => 'array',
|
||||
'scope_tags' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ class RestoreRun extends Model
|
||||
'preview' => 'array',
|
||||
'results' => 'array',
|
||||
'metadata' => 'array',
|
||||
'group_mapping' => 'array',
|
||||
'started_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);
|
||||
}
|
||||
|
||||
// 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
194
app/Services/AssignmentBackupService.php
Normal file
194
app/Services/AssignmentBackupService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
466
app/Services/AssignmentRestoreService.php
Normal file
466
app/Services/AssignmentRestoreService.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
111
app/Services/Graph/AssignmentFetcher.php
Normal file
111
app/Services/Graph/AssignmentFetcher.php
Normal 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'] ?? [];
|
||||
}
|
||||
}
|
||||
54
app/Services/Graph/AssignmentFilterResolver.php
Normal file
54
app/Services/Graph/AssignmentFilterResolver.php
Normal 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'] ?? [];
|
||||
});
|
||||
}
|
||||
}
|
||||
123
app/Services/Graph/GroupResolver.php
Normal file
123
app/Services/Graph/GroupResolver.php
Normal 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));
|
||||
}
|
||||
}
|
||||
89
app/Services/Graph/ScopeTagResolver.php
Normal file
89
app/Services/Graph/ScopeTagResolver.php
Normal 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 [];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentBackupService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@ -16,6 +17,8 @@ public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly AssignmentBackupService $assignmentBackupService,
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -29,6 +32,8 @@ public function createBackupSet(
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
?string $name = null,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
): BackupSet {
|
||||
$this->assertActiveTenant($tenant);
|
||||
|
||||
@ -37,7 +42,7 @@ public function createBackupSet(
|
||||
->whereIn('id', $policyIds)
|
||||
->get();
|
||||
|
||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) {
|
||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags) {
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup',
|
||||
@ -50,7 +55,14 @@ public function createBackupSet(
|
||||
$itemsCreated = 0;
|
||||
|
||||
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) {
|
||||
$failures[] = $failure;
|
||||
@ -92,6 +104,31 @@ public function createBackupSet(
|
||||
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;
|
||||
}
|
||||
|
||||
@ -106,6 +143,8 @@ public function addPoliciesToSet(
|
||||
array $policyIds,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
): BackupSet {
|
||||
$this->assertActiveTenant($tenant);
|
||||
|
||||
@ -114,9 +153,20 @@ public function addPoliciesToSet(
|
||||
}
|
||||
|
||||
$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));
|
||||
|
||||
if (empty($policyIds)) {
|
||||
if (empty($policyIds) && $softDeletedItems->isEmpty()) {
|
||||
return $backupSet->refresh();
|
||||
}
|
||||
|
||||
@ -130,7 +180,14 @@ public function addPoliciesToSet(
|
||||
$itemsCreated = 0;
|
||||
|
||||
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) {
|
||||
$failures[] = $failure;
|
||||
@ -159,6 +216,7 @@ public function addPoliciesToSet(
|
||||
'metadata' => [
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'added_count' => $itemsCreated,
|
||||
'restored_count' => $softDeletedItems->count(),
|
||||
'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}}
|
||||
*/
|
||||
private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array
|
||||
{
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail);
|
||||
private function snapshotPolicy(
|
||||
Tenant $tenant,
|
||||
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'])) {
|
||||
return [null, $snapshot['failure']];
|
||||
// Check for capture failure
|
||||
if (isset($captureResult['failure'])) {
|
||||
return [null, $captureResult['failure']];
|
||||
}
|
||||
|
||||
$payload = $snapshot['payload'];
|
||||
$metadata = $snapshot['metadata'] ?? [];
|
||||
$metadataWarnings = $snapshot['warnings'] ?? [];
|
||||
$version = $captureResult['version'];
|
||||
$captured = $captureResult['captured'];
|
||||
$payload = $captured['payload'];
|
||||
$metadata = $captured['metadata'] ?? [];
|
||||
$metadataWarnings = $captured['warnings'] ?? [];
|
||||
|
||||
// Validate snapshot
|
||||
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
||||
$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));
|
||||
}
|
||||
|
||||
// Create BackupItem as a copy/reference of the PolicyVersion
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_version_id' => $version->id, // Link to version
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => $payload,
|
||||
'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];
|
||||
}
|
||||
|
||||
|
||||
369
app/Services/Intune/PolicyCaptureOrchestrator.php
Normal file
369
app/Services/Intune/PolicyCaptureOrchestrator.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
@ -25,6 +26,7 @@ public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -73,6 +75,7 @@ public function execute(
|
||||
bool $dryRun = true,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
array $groupMapping = [],
|
||||
): RestoreRun {
|
||||
$this->assertActiveContext($tenant, $backupSet);
|
||||
|
||||
@ -90,8 +93,28 @@ public function execute(
|
||||
'preview' => $preview,
|
||||
'started_at' => CarbonImmutable::now(),
|
||||
'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 = [];
|
||||
$hardFailures = 0;
|
||||
|
||||
@ -265,6 +288,31 @@ public function execute(
|
||||
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];
|
||||
|
||||
if ($settingsApply !== null) {
|
||||
@ -285,6 +333,14 @@ public function execute(
|
||||
$result['reason'] = 'Some settings require attention';
|
||||
}
|
||||
|
||||
if ($assignmentOutcomes !== null) {
|
||||
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
|
||||
}
|
||||
|
||||
if ($assignmentSummary !== null) {
|
||||
$result['assignment_summary'] = $assignmentSummary;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
|
||||
$appliedPolicyId = $item->policy_identifier;
|
||||
|
||||
@ -105,7 +105,7 @@ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $pe
|
||||
$overall = match (true) {
|
||||
$hasErrors => 'error',
|
||||
$hasMissing => 'missing',
|
||||
default => 'ok',
|
||||
default => 'granted',
|
||||
};
|
||||
|
||||
return [
|
||||
@ -148,7 +148,7 @@ public function configuredGrantedStatuses(): array
|
||||
|
||||
foreach ($configured as $key) {
|
||||
$normalized[$key] = [
|
||||
'status' => 'ok',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'configured'],
|
||||
];
|
||||
}
|
||||
@ -204,7 +204,7 @@ private function fetchLivePermissions(Tenant $tenant): array
|
||||
|
||||
foreach ($grantedPermissions as $permission) {
|
||||
$normalized[$permission] = [
|
||||
'status' => 'ok',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()],
|
||||
];
|
||||
}
|
||||
|
||||
@ -5,6 +5,10 @@
|
||||
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 Carbon\CarbonImmutable;
|
||||
|
||||
class VersionService
|
||||
@ -12,6 +16,10 @@ class VersionService
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly AssignmentFetcher $assignmentFetcher,
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
private readonly ScopeTagResolver $scopeTagResolver,
|
||||
) {}
|
||||
|
||||
public function captureVersion(
|
||||
@ -19,6 +27,8 @@ public function captureVersion(
|
||||
array $payload,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
?array $assignments = null,
|
||||
?array $scopeTags = null,
|
||||
): PolicyVersion {
|
||||
$versionNumber = $this->nextVersionNumber($policy);
|
||||
|
||||
@ -32,6 +42,10 @@ public function captureVersion(
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => $payload,
|
||||
'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(
|
||||
@ -56,7 +70,12 @@ public function captureFromGraph(
|
||||
Policy $policy,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
bool $includeAssignments = true,
|
||||
bool $includeScopeTags = true,
|
||||
): PolicyVersion {
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||
|
||||
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
|
||||
|
||||
if (isset($snapshot['failure'])) {
|
||||
@ -65,16 +84,123 @@ public function captureFromGraph(
|
||||
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(
|
||||
policy: $policy,
|
||||
payload: $snapshot['payload'],
|
||||
payload: $payload,
|
||||
createdBy: $createdBy,
|
||||
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
|
||||
{
|
||||
$current = PolicyVersion::query()
|
||||
|
||||
@ -70,6 +70,19 @@
|
||||
'fallback_body_shape' => 'wrapped',
|
||||
],
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
|
||||
@ -56,6 +56,18 @@
|
||||
'description' => 'Read directory data needed for tenant health checks.',
|
||||
'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',
|
||||
'type' => 'application',
|
||||
@ -66,8 +78,14 @@
|
||||
// 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.
|
||||
// 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' => [
|
||||
// Tatsächlich granted (aus Entra ID Screenshot):
|
||||
// Tatsächlich granted (aus Entra ID):
|
||||
'Device.Read.All',
|
||||
'DeviceManagementConfiguration.Read.All',
|
||||
'DeviceManagementConfiguration.ReadWrite.All',
|
||||
@ -76,6 +94,10 @@
|
||||
'Directory.Read.All',
|
||||
'User.Read',
|
||||
'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):
|
||||
// Wenn diese fehlen, erscheinen sie als "missing" in der UI
|
||||
|
||||
35
database/factories/BackupItemFactory.php
Normal file
35
database/factories/BackupItemFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
database/factories/BackupSetFactory.php
Normal file
30
database/factories/BackupSetFactory.php
Normal 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -17,12 +18,13 @@ class PolicyFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => \App\Models\Tenant::factory(),
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'external_id' => fake()->uuid(),
|
||||
'display_name' => fake()->words(3, true),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10AndLater',
|
||||
'metadata' => ['key' => 'value'],
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
|
||||
'last_synced_at' => now(),
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,8 +22,8 @@ public function definition(): array
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'policy_id' => Policy::factory(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10AndLater',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
|
||||
'created_by' => fake()->safeEmail(),
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['example' => true],
|
||||
|
||||
35
database/factories/RestoreRunFactory.php
Normal file
35
database/factories/RestoreRunFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -18,10 +18,16 @@ public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->company(),
|
||||
'tenant_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',
|
||||
'is_current' => true,
|
||||
'is_current' => false,
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
154
docs/PERMISSIONS.md
Normal 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)
|
||||
@ -48,11 +48,18 @@
|
||||
@if (empty($canaries))
|
||||
<div class="text-sm text-gray-700">No canary results recorded.</div>
|
||||
@else
|
||||
<ul class="space-y-1 text-sm text-gray-800">
|
||||
<ul class="space-y-1 text-sm">
|
||||
@foreach ($canaries as $key => $status)
|
||||
<li>
|
||||
<span class="font-semibold">{{ $key }}:</span>
|
||||
<span class="{{ $status === 'ok' ? 'text-green-700' : 'text-amber-700' }}">{{ $status }}</span>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-800">{{ $key }}:</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>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
@ -42,12 +42,90 @@
|
||||
</span>
|
||||
</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">
|
||||
{{ $item['reason'] }}
|
||||
{{ $itemReason }}
|
||||
</div>
|
||||
@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']))
|
||||
@php
|
||||
$createdMode = $item['created_policy_mode'] ?? null;
|
||||
|
||||
@ -0,0 +1 @@
|
||||
@livewire('policy-version-assignments-widget', ['version' => $record])
|
||||
@ -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>
|
||||
653
specs/004-assignments-scope-tags/data-model.md
Normal file
653
specs/004-assignments-scope-tags/data-model.md
Normal 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`
|
||||
461
specs/004-assignments-scope-tags/plan.md
Normal file
461
specs/004-assignments-scope-tags/plan.md
Normal 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`
|
||||
616
specs/004-assignments-scope-tags/quickstart.md
Normal file
616
specs/004-assignments-scope-tags/quickstart.md
Normal 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)
|
||||
566
specs/004-assignments-scope-tags/research.md
Normal file
566
specs/004-assignments-scope-tags/research.md
Normal 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`
|
||||
@ -28,59 +28,58 @@ ## Scope
|
||||
- **Policy Types**: `settingsCatalogPolicy` only (initially)
|
||||
- **Graph Endpoints**:
|
||||
- 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)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 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:**
|
||||
1. **Given** I create a new Backup Set for Settings Catalog policies,
|
||||
**When** I enable the checkbox "Include Assignments & Scope Tags",
|
||||
**Then** the backup captures:
|
||||
- Assignment list (groups, users, devices with include/exclude mode)
|
||||
- Scope Tag IDs referenced by the policy
|
||||
- Metadata about assignment count and scope tag names
|
||||
1. **Given** I add policies to a Backup Set (or capture a snapshot from the Policy view),
|
||||
**When** I enable "Include assignments" and/or "Include scope tags",
|
||||
**Then** the capture stores:
|
||||
- Assignments on the PolicyVersion (include/exclude targets + filters)
|
||||
- Scope tags on the PolicyVersion as `{ids, names}`
|
||||
- A BackupItem linked via `policy_version_id` that copies assignments for restore
|
||||
|
||||
2. **Given** I view a Backup Set with assignments included,
|
||||
**When** I expand a Backup Item detail,
|
||||
**Then** I see:
|
||||
- "Assignments: 3 groups, 2 users" summary
|
||||
- "Scope Tags: Default, HR-Admins" list
|
||||
- JSON tab with full assignment payload
|
||||
2. **Given** I create a Backup Set,
|
||||
**When** I complete the form,
|
||||
**Then** no assignments/scope tags checkbox appears on that screen (selection happens when adding policies).
|
||||
|
||||
3. **Given** I create a Backup Set without enabling the checkbox,
|
||||
**When** the backup completes,
|
||||
**Then** assignments and scope tags are NOT captured (payload-only backup)
|
||||
3. **Given** I disable either checkbox,
|
||||
**When** the capture completes,
|
||||
**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:**
|
||||
1. **Given** I view a Settings Catalog policy,
|
||||
**When** I navigate to the "Assignments" tab,
|
||||
1. **Given** I view a Settings Catalog Policy Version,
|
||||
**When** assignments were captured,
|
||||
**Then** I see:
|
||||
- Table with columns: Type (Group/User/Device), Name, Mode (Include/Exclude), ID
|
||||
- "Scope Tags" section showing: Default, HR-Admins (editable IDs)
|
||||
- "Not assigned" message if no assignments exist
|
||||
- Include/Exclude group targets with group display name (or "Unknown Group (ID: ...)")
|
||||
- Filter name (if present) with filter mode (include/exclude)
|
||||
- Scope tags list from the version
|
||||
|
||||
2. **Given** a policy has 10 assignments,
|
||||
**When** I filter by "Include only" or "Exclude only",
|
||||
**Then** the table filters accordingly
|
||||
2. **Given** assignments were not captured for this version,
|
||||
**When** I open the assignments panel,
|
||||
**Then** I see "Assignments were not captured for this version."
|
||||
|
||||
3. **Given** assignments include deleted groups (orphaned IDs),
|
||||
**When** I view the assignments tab,
|
||||
**Then** orphaned entries show as "Unknown Group (ID: abc-123)" with warning badge
|
||||
3. **Given** scope tags were not captured,
|
||||
**When** I view the version,
|
||||
**Then** I see a "Scope tags not captured" empty state.
|
||||
|
||||
---
|
||||
|
||||
@ -135,45 +134,51 @@ ## Functional Requirements
|
||||
|
||||
### 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:
|
||||
1. Try: `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
2. If empty/fails: Try `$expand=assignments` on policy fetch
|
||||
3. Store:
|
||||
- Assignment array (each with: `target` object, `id`, `intent`, filters)
|
||||
- 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)
|
||||
3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true` in PolicyVersion metadata.
|
||||
- This flag covers any failure during assignment capture/enrichment (fetch, group resolution, filter resolution).
|
||||
|
||||
**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:
|
||||
```json
|
||||
{
|
||||
"assignment_count": 5,
|
||||
"scope_tag_ids": ["0", "abc-123"],
|
||||
"scope_tag_names": ["Default", "HR-Admins"],
|
||||
"has_orphaned_assignments": false
|
||||
}
|
||||
```
|
||||
**FR-004.4**: System MUST store assignments and scope tags on PolicyVersion:
|
||||
- `policy_versions.assignments` (array, nullable)
|
||||
- `policy_versions.scope_tags` as `{ids: [], names: []}` (nullable)
|
||||
- hashes for deduplication (`assignments_hash`, `scope_tags_hash`)
|
||||
BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore.
|
||||
|
||||
**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
|
||||
|
||||
**FR-004.6**: Policy View MUST show an "Assignments" tab for Settings Catalog policies displaying:
|
||||
- Assignments table (type, name, mode, ID)
|
||||
**FR-004.6**: Policy Version view MUST show an assignments panel for Settings Catalog versions displaying:
|
||||
- Include/Exclude targets with group display name or "Unknown Group (ID: ...)"
|
||||
- Assignment filter name + filter mode (include/exclude) when present
|
||||
- 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.
|
||||
|
||||
**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
|
||||
|
||||
**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:
|
||||
- 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
|
||||
2. Skip assignments marked "Skip" in group mapping
|
||||
3. Preserve include/exclude intent and filters
|
||||
4. Execute restore via DELETE-then-CREATE pattern:
|
||||
- Step 1: GET existing assignments from target policy
|
||||
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`)
|
||||
- Step 3: POST each new/mapped assignment (via POST `/assignments`)
|
||||
4. Execute restore via assign action when supported:
|
||||
- Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
|
||||
- Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
|
||||
- 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:
|
||||
- 204 No Content on DELETE = success
|
||||
- 201 Created on POST = success
|
||||
- Log request-id/client-request-id on any failure
|
||||
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:
|
||||
- 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.17**: During restore, system SHOULD preserve Scope Tag IDs if they exist in target tenant, or:
|
||||
- Log warning if Scope Tag ID doesn't exist in target
|
||||
- Allow policy creation to proceed (Graph API default behavior)
|
||||
**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 a warning
|
||||
- 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".
|
||||
|
||||
@ -223,7 +231,7 @@ ### Scope Tags
|
||||
|
||||
## 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.
|
||||
|
||||
@ -235,12 +243,33 @@ ## Non-Functional Requirements
|
||||
|
||||
## 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
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$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
|
||||
{
|
||||
"assignment_count": 5,
|
||||
"scope_tag_ids": ["0", "123"],
|
||||
"scope_tag_names": ["Default", "HR"],
|
||||
"ids": ["0", "123"],
|
||||
"names": ["Default", "HR"]
|
||||
}
|
||||
```
|
||||
|
||||
### `policy_versions.metadata` JSONB schema
|
||||
|
||||
```json
|
||||
{
|
||||
"has_assignments": true,
|
||||
"has_scope_tags": true,
|
||||
"has_orphaned_assignments": 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
|
||||
@ -280,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
|
||||
- Client-side filter to extract assignments
|
||||
- **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`
|
||||
- Body: Single assignment object
|
||||
- Returns: 201 Created with assignment object
|
||||
- **POST** `/deviceManagement/configurationPolicies/{id}/assign`
|
||||
- Body: `{ "assignments": [ ... ] }`
|
||||
- Returns: 200/204 on success (no per-assignment IDs)
|
||||
- Example:
|
||||
```json
|
||||
{
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||
"groupId": "abc-123-def"
|
||||
},
|
||||
"intent": "apply"
|
||||
"assignments": [
|
||||
{
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||
"groupId": "abc-123-def"
|
||||
},
|
||||
"intent": "apply"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Fallback** (when `/assign` is unsupported):
|
||||
- **GET** `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object)
|
||||
|
||||
- **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- Body: Assignment object (partial update)
|
||||
- Returns: 200 OK with updated assignment
|
||||
|
||||
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- Returns: 204 No Content
|
||||
|
||||
- **Restore Strategy**: DELETE all existing assignments, then POST new ones (atomic via transaction pattern)
|
||||
- **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
|
||||
|
||||
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
|
||||
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
|
||||
@ -321,6 +362,11 @@ ### Endpoints to Add (Production-Tested Strategies)
|
||||
- For Scope Tag resolution (cache 1 hour)
|
||||
- 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
|
||||
|
||||
Add to `config/graph_contracts.php`:
|
||||
@ -331,7 +377,7 @@ ### Graph Contract Updates
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'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_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
@ -348,18 +394,17 @@ ### Graph Contract Updates
|
||||
|
||||
## UI Mockups (Wireframe Descriptions)
|
||||
|
||||
### Policy View - Assignments Tab
|
||||
### Policy Version View - Assignments Panel
|
||||
|
||||
```
|
||||
[General] [Settings] [Assignments] [JSON]
|
||||
[General] [Settings] [JSON]
|
||||
|
||||
Assignments (5)
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Type │ Name │ Mode │ ID │
|
||||
│ Type │ Name │ Filter │ ID │
|
||||
├─────────┼───────────────────┼─────────┼─────────┤
|
||||
│ Group │ All Users │ Include │ abc-123 │
|
||||
│ Group │ Contractors │ Exclude │ def-456 │
|
||||
│ User │ john@contoso.com │ Include │ ghi-789 │
|
||||
│ Include group │ All Users │ Test (include) │ abc-123 │
|
||||
│ Exclude group │ Contractors │ - │ def-456 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Scope Tags (2)
|
||||
@ -367,18 +412,20 @@ ### Policy View - Assignments Tab
|
||||
• 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]
|
||||
|
||||
☑ Include Assignments & Scope Tags
|
||||
Captures group/user targeting and RBAC scope.
|
||||
Adds ~2-5 KB per policy with assignments.
|
||||
☑ Include assignments
|
||||
Captures include/exclude targeting and filters.
|
||||
|
||||
[Cancel] [Create Backup]
|
||||
☑ Include scope tags
|
||||
Captures policy scope tag IDs.
|
||||
|
||||
[Cancel] [Add Policies]
|
||||
```
|
||||
|
||||
### Restore Wizard - Group Mapping Step
|
||||
@ -408,15 +455,18 @@ ### Unit Tests
|
||||
- `AssignmentFetcherTest`: Mock Graph responses, test parsing
|
||||
- `GroupMapperTest`: Test ID resolution, mapping logic
|
||||
- `ScopeTagResolverTest`: Test caching, name resolution
|
||||
- `AssignmentFilterResolverTest`: Test caching and ID filtering
|
||||
|
||||
### Feature Tests
|
||||
- `BackupWithAssignmentsTest`: E2E backup creation with checkbox
|
||||
- `PolicyViewAssignmentsTabTest`: UI rendering, orphaned IDs
|
||||
- `BackupWithAssignmentsConsistencyTest`: PolicyVersion as source of truth
|
||||
- `VersionCaptureWithAssignmentsTest`: Snapshot capture with assignments/scope tags
|
||||
- `PolicyVersionViewAssignmentsTest`: UI rendering, orphaned IDs, filters
|
||||
- `RestoreGroupMappingTest`: Wizard flow, mapping persistence
|
||||
- `RestoreAssignmentApplicationTest`: Graph API calls, outcomes
|
||||
|
||||
### 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 different tenant (group mapping wizard)
|
||||
- Handle orphaned group IDs gracefully
|
||||
@ -426,10 +476,10 @@ ### Manual QA
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Backup with Assignments (MVP)
|
||||
- Add checkbox to Backup form
|
||||
- Add checkboxes on Add Policies + Capture Snapshot actions
|
||||
- Fetch assignments from Graph
|
||||
- Store in `backup_items.assignments`
|
||||
- Display in Policy View (read-only)
|
||||
- Store on PolicyVersion (copy assignments to BackupItem)
|
||||
- Display in Policy Version view (read-only)
|
||||
- **Duration**: ~8-12 hours
|
||||
|
||||
### Phase 2: Restore with Group Mapping
|
||||
@ -440,7 +490,7 @@ ### Phase 2: Restore with Group Mapping
|
||||
|
||||
### Phase 3: Scope Tags
|
||||
- Resolve Scope Tag names
|
||||
- Display in UI
|
||||
- Display in Policy Version view
|
||||
- Handle restore warnings
|
||||
- **Duration**: ~4-6 hours
|
||||
|
||||
@ -461,7 +511,7 @@ ## Risks & Mitigations
|
||||
|
||||
| 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 |
|
||||
| Group IDs change across tenants | Group name-based matching fallback |
|
||||
| Scope Tag IDs don't exist in target | Log warning, allow policy creation |
|
||||
@ -470,8 +520,8 @@ ## Risks & Mitigations
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Backup checkbox functional, assignments captured
|
||||
2. ✅ Policy View shows assignments tab with accurate data
|
||||
1. ✅ Capture checkboxes functional, assignments captured
|
||||
2. ✅ Policy Version view shows assignments widget with accurate data
|
||||
3. ✅ Group Mapping wizard handles 100+ groups smoothly
|
||||
4. ✅ Restore applies assignments with 90%+ success rate
|
||||
5. ✅ Audit logs record all mapping decisions
|
||||
|
||||
853
specs/004-assignments-scope-tags/tasks.md
Normal file
853
specs/004-assignments-scope-tags/tasks.md
Normal 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)
|
||||
228
specs/005-policy-lifecycle/spec.md
Normal file
228
specs/005-policy-lifecycle/spec.md
Normal 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
|
||||
135
tests/Feature/BackupItemReaddTest.php
Normal file
135
tests/Feature/BackupItemReaddTest.php
Normal 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);
|
||||
});
|
||||
264
tests/Feature/BackupWithAssignmentsConsistencyTest.php
Normal file
264
tests/Feature/BackupWithAssignmentsConsistencyTest.php
Normal 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);
|
||||
});
|
||||
@ -2,16 +2,43 @@
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
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
|
||||
{
|
||||
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',
|
||||
'display_name' => 'Policy A',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$policyB = Policy::create([
|
||||
@ -67,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy B',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$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,
|
||||
])->callTableAction('addPolicies', data: [
|
||||
'policy_ids' => [$policyA->id, $policyB->id],
|
||||
'include_assignments' => false,
|
||||
'include_scope_tags' => true,
|
||||
]);
|
||||
|
||||
$backupSet->refresh();
|
||||
|
||||
expect($backupSet->item_count)->toBe(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', [
|
||||
'action' => 'backup.created',
|
||||
|
||||
64
tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php
Normal file
64
tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php
Normal 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'],
|
||||
]);
|
||||
});
|
||||
@ -21,6 +21,7 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$otherTenant = Tenant::create([
|
||||
@ -35,6 +36,7 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy B',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -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]['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->assertOk();
|
||||
$response->assertSee('Graph bulk apply failed');
|
||||
$response->assertSee('Setting missing');
|
||||
$response->assertSee('req-setting-404');
|
||||
$response->assertSee('Assignments: 0 success');
|
||||
$response->assertSee('Assignment details');
|
||||
$response->assertSee('Graph create failed');
|
||||
});
|
||||
|
||||
@ -83,7 +83,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
$this->assertDatabaseHas('tenant_permissions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'status' => 'ok',
|
||||
'status' => 'granted',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
90
tests/Feature/PolicyVersionViewAssignmentsTest.php
Normal file
90
tests/Feature/PolicyVersionViewAssignmentsTest.php
Normal 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');
|
||||
});
|
||||
249
tests/Feature/RestoreAssignmentApplicationTest.php
Normal file
249
tests/Feature/RestoreAssignmentApplicationTest.php
Normal 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');
|
||||
});
|
||||
173
tests/Feature/RestoreGroupMappingTest.php
Normal file
173
tests/Feature/RestoreGroupMappingTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
200
tests/Feature/VersionCaptureWithAssignmentsTest.php
Normal file
200
tests/Feature/VersionCaptureWithAssignmentsTest.php
Normal 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);
|
||||
});
|
||||
163
tests/Unit/AssignmentFetcherTest.php
Normal file
163
tests/Unit/AssignmentFetcherTest.php
Normal 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([]);
|
||||
});
|
||||
65
tests/Unit/AssignmentFilterResolverTest.php
Normal file
65
tests/Unit/AssignmentFilterResolverTest.php
Normal 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);
|
||||
});
|
||||
157
tests/Unit/BackupItemTest.php
Normal file
157
tests/Unit/BackupItemTest.php
Normal 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);
|
||||
});
|
||||
179
tests/Unit/GroupResolverTest.php
Normal file
179
tests/Unit/GroupResolverTest.php
Normal 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);
|
||||
});
|
||||
181
tests/Unit/RestoreRunTest.php
Normal file
181
tests/Unit/RestoreRunTest.php
Normal 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);
|
||||
});
|
||||
142
tests/Unit/ScopeTagResolverTest.php
Normal file
142
tests/Unit/ScopeTagResolverTest.php
Normal 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']);
|
||||
});
|
||||
|
||||
@ -42,14 +42,14 @@ function requiredPermissions(): array
|
||||
TenantPermission::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'permission_key' => $permission['key'],
|
||||
'status' => 'ok',
|
||||
'status' => 'granted',
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(TenantPermissionService::class)->compare($tenant);
|
||||
|
||||
expect($result['overall_status'])->toBe('ok');
|
||||
expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'ok')->count())
|
||||
expect($result['overall_status'])->toBe('granted');
|
||||
expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'granted')->count())
|
||||
->toBe(count(requiredPermissions()));
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user