spec(004): Add comprehensive implementation plan for Assignments & Scope Tags

- plan.md: 10 implementation phases, MVP scope (16-22h), full scope (30-40h)
- research.md: 7 research questions answered (JSONB storage, Graph API fallback, group resolution, etc.)
- data-model.md: Database migrations, model changes, audit log entries
- quickstart.md: Developer setup, manual test scenarios, debugging tools
This commit is contained in:
Ahmed Darrazi 2025-12-22 01:43:30 +01:00
parent 9eb3a849e2
commit cd0e569bbc
4 changed files with 2296 additions and 0 deletions

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`