diff --git a/specs/004-assignments-scope-tags/data-model.md b/specs/004-assignments-scope-tags/data-model.md new file mode 100644 index 0000000..036e497 --- /dev/null +++ b/specs/004-assignments-scope-tags/data-model.md @@ -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 +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 +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 + '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 + '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 +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` diff --git a/specs/004-assignments-scope-tags/plan.md b/specs/004-assignments-scope-tags/plan.md new file mode 100644 index 0000000..70416cd --- /dev/null +++ b/specs/004-assignments-scope-tags/plan.md @@ -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` diff --git a/specs/004-assignments-scope-tags/quickstart.md b/specs/004-assignments-scope-tags/quickstart.md new file mode 100644 index 0000000..71fedaa --- /dev/null +++ b/specs/004-assignments-scope-tags/quickstart.md @@ -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) diff --git a/specs/004-assignments-scope-tags/research.md b/specs/004-assignments-scope-tags/research.md new file mode 100644 index 0000000..c5810cd --- /dev/null +++ b/specs/004-assignments-scope-tags/research.md @@ -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`