feat/005-bulk-operations #5

Merged
ahmido merged 25 commits from feat/005-bulk-operations into dev 2025-12-25 13:32:37 +00:00
67 changed files with 8666 additions and 165 deletions
Showing only changes of commit 629bbef6e9 - Show all commits

View File

@ -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.

View File

@ -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,
);
}
}

View File

@ -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()

View File

@ -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')

View File

@ -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.')

View File

@ -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(),
]);
}
}

View File

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

View File

@ -248,12 +248,37 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('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',
},

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ class BackupItem extends Model
protected $casts = [
'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');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@
use App\Models\BackupSet;
use App\Models\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];
}

View File

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

View File

@ -7,6 +7,7 @@
use App\Models\Policy;
use App\Models\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;

View File

@ -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()],
];
}

View File

@ -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()

View File

@ -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',

View File

@ -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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
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' => [],
];
}
}

View File

@ -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],

View File

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

View File

@ -18,10 +18,16 @@ public function definition(): array
{
return [
'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' => [],
];
}
}

View File

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

View File

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

View File

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

View File

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

154
docs/PERMISSIONS.md Normal file
View File

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

View File

@ -48,11 +48,18 @@
@if (empty($canaries))
<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>

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,59 +28,58 @@ ## Scope
- **Policy Types**: `settingsCatalogPolicy` only (initially)
- **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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,16 +2,43 @@
use App\Filament\Resources\BackupSetResource;
use App\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',

View File

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

View File

@ -21,6 +21,7 @@
'policy_type' => 'deviceConfiguration',
'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();

View File

@ -169,9 +169,28 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->requestCalls[0]['method'])->toBe('POST');
expect($client->requestCalls[0]['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');
});

View File

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

View File

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

View File

@ -0,0 +1,249 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class RestoreAssignmentGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{method:string,path:string,payload:array|null}>
*/
public array $requestCalls = [];
/**
* @param array<int, GraphResponse> $requestResponses
*/
public function __construct(
private readonly GraphResponse $applyPolicyResponse,
private array $requestResponses = [],
) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return $this->applyPolicyResponse;
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requestCalls[] = [
'method' => strtoupper($method),
'path' => $path,
'payload' => $options['json'] ?? null,
];
return array_shift($this->requestResponses) ?? new GraphResponse(true, []);
}
}
test('restore applies assignments with mapped groups', function () {
$applyResponse = new GraphResponse(true, []);
$requestResponses = [
new GraphResponse(true, []), // assign action
];
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog Alpha',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
'assignments' => [
[
'id' => 'assignment-1',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source One',
],
],
[
'id' => 'assignment-2',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-2',
],
],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
groupMapping: [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(2);
expect($summary['failed'])->toBe(0);
$postCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST')
->values();
expect($postCalls)->toHaveCount(1);
expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign');
$payloadAssignments = $postCalls[0]['payload']['assignments'] ?? [];
$groupIds = collect($payloadAssignments)->pluck('target.groupId')->all();
expect($groupIds)->toBe(['target-group-1', 'target-group-2']);
expect($payloadAssignments[0])->not->toHaveKey('id');
});
test('restore handles assignment failures gracefully', function () {
$applyResponse = new GraphResponse(true, []);
$requestResponses = [
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
['code' => 'BadRequest', 'message' => 'Bad request'],
], [], [
'error_code' => 'BadRequest',
'error_message' => 'Bad request',
]), // assign action fails
];
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'scp-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog Alpha',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
'assignments' => [
[
'id' => 'assignment-1',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
],
],
[
'id' => 'assignment-2',
'intent' => 'apply',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-2',
],
],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
groupMapping: [
'source-group-1' => 'target-group-1',
'source-group-2' => 'target-group-2',
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(0);
expect($summary['failed'])->toBe(2);
expect($run->results[0]['status'])->toBe('partial');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,14 +42,14 @@ function requiredPermissions(): array
TenantPermission::create([
'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()));
});