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:
parent
9eb3a849e2
commit
cd0e569bbc
653
specs/004-assignments-scope-tags/data-model.md
Normal file
653
specs/004-assignments-scope-tags/data-model.md
Normal file
@ -0,0 +1,653 @@
|
||||
# Feature 004: Assignments & Scope Tags - Data Model
|
||||
|
||||
## Overview
|
||||
This document defines the database schema changes, model relationships, and data structures for the Assignments & Scope Tags feature.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### Migration 1: Add `assignments` column to `backup_items`
|
||||
|
||||
**File**: `database/migrations/xxxx_add_assignments_to_backup_items.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$table->json('assignments')->nullable()->after('metadata');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$table->dropColumn('assignments');
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**JSONB Structure** (`backup_items.assignments`):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "abc-123-def",
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||
"groupId": "group-abc-123",
|
||||
"deviceAndAppManagementAssignmentFilterId": null,
|
||||
"deviceAndAppManagementAssignmentFilterType": "none"
|
||||
},
|
||||
"intent": "apply",
|
||||
"settings": null,
|
||||
"source": "direct"
|
||||
},
|
||||
{
|
||||
"id": "def-456-ghi",
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.exclusionGroupAssignmentTarget",
|
||||
"groupId": "group-def-456"
|
||||
},
|
||||
"intent": "exclude"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Index** (optional, for analytics queries):
|
||||
```php
|
||||
// Add GIN index for JSONB queries
|
||||
DB::statement('CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments)');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migration 2: Extend `backup_items.metadata` JSONB
|
||||
|
||||
**Purpose**: Store assignment summary in metadata for quick access
|
||||
|
||||
**Updated Schema** (`backup_items.metadata`):
|
||||
```json
|
||||
{
|
||||
// Existing fields
|
||||
"policy_name": "Windows Security Baseline",
|
||||
"policy_type": "settingsCatalogPolicy",
|
||||
"tenant_name": "Contoso Corp",
|
||||
|
||||
// NEW: Assignment metadata
|
||||
"assignment_count": 5,
|
||||
"scope_tag_ids": ["0", "abc-123", "def-456"],
|
||||
"scope_tag_names": ["Default", "HR-Admins", "Finance-Admins"],
|
||||
"has_orphaned_assignments": false,
|
||||
"assignments_fetch_failed": false
|
||||
}
|
||||
```
|
||||
|
||||
**No Migration Needed**: `metadata` column already exists as JSONB, just update application code to populate these fields.
|
||||
|
||||
---
|
||||
|
||||
### Migration 3: Add `group_mapping` column to `restore_runs`
|
||||
|
||||
**File**: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('restore_runs', function (Blueprint $table) {
|
||||
$table->json('group_mapping')->nullable()->after('results');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('restore_runs', function (Blueprint $table) {
|
||||
$table->dropColumn('group_mapping');
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**JSONB Structure** (`restore_runs.group_mapping`):
|
||||
```json
|
||||
{
|
||||
"source-group-abc-123": "target-group-xyz-789",
|
||||
"source-group-def-456": "target-group-uvw-012",
|
||||
"source-group-ghi-789": "SKIP"
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**: Maps source tenant group IDs to target tenant group IDs during restore. Special value `"SKIP"` means do not restore assignments targeting that group.
|
||||
|
||||
---
|
||||
|
||||
## Model Changes
|
||||
|
||||
### BackupItem Model
|
||||
|
||||
**File**: `app/Models/BackupItem.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BackupItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'backup_set_id',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments', // NEW
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'metadata' => 'array',
|
||||
'assignments' => 'array', // NEW
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function backupSet()
|
||||
{
|
||||
return $this->belongsTo(BackupSet::class);
|
||||
}
|
||||
|
||||
// NEW: Assignment helpers
|
||||
public function getAssignmentCountAttribute(): int
|
||||
{
|
||||
return count($this->assignments ?? []);
|
||||
}
|
||||
|
||||
public function hasAssignments(): bool
|
||||
{
|
||||
return !empty($this->assignments);
|
||||
}
|
||||
|
||||
public function getGroupIdsAttribute(): array
|
||||
{
|
||||
return collect($this->assignments ?? [])
|
||||
->pluck('target.groupId')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getScopeTagIdsAttribute(): array
|
||||
{
|
||||
return $this->metadata['scope_tag_ids'] ?? ['0'];
|
||||
}
|
||||
|
||||
public function getScopeTagNamesAttribute(): array
|
||||
{
|
||||
return $this->metadata['scope_tag_names'] ?? ['Default'];
|
||||
}
|
||||
|
||||
public function hasOrphanedAssignments(): bool
|
||||
{
|
||||
return $this->metadata['has_orphaned_assignments'] ?? false;
|
||||
}
|
||||
|
||||
public function assignmentsFetchFailed(): bool
|
||||
{
|
||||
return $this->metadata['assignments_fetch_failed'] ?? false;
|
||||
}
|
||||
|
||||
// NEW: Scope for filtering policies with assignments
|
||||
public function scopeWithAssignments($query)
|
||||
{
|
||||
return $query->whereNotNull('assignments')
|
||||
->whereRaw('json_array_length(assignments) > 0');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### RestoreRun Model
|
||||
|
||||
**File**: `app/Models/RestoreRun.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RestoreRun extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'backup_set_id',
|
||||
'target_tenant_id',
|
||||
'status',
|
||||
'results',
|
||||
'group_mapping', // NEW
|
||||
'started_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'results' => 'array',
|
||||
'group_mapping' => 'array', // NEW
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function backupSet()
|
||||
{
|
||||
return $this->belongsTo(BackupSet::class);
|
||||
}
|
||||
|
||||
public function targetTenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'target_tenant_id');
|
||||
}
|
||||
|
||||
// NEW: Group mapping helpers
|
||||
public function hasGroupMapping(): bool
|
||||
{
|
||||
return !empty($this->group_mapping);
|
||||
}
|
||||
|
||||
public function getMappedGroupId(string $sourceGroupId): ?string
|
||||
{
|
||||
return $this->group_mapping[$sourceGroupId] ?? null;
|
||||
}
|
||||
|
||||
public function isGroupSkipped(string $sourceGroupId): bool
|
||||
{
|
||||
return $this->group_mapping[$sourceGroupId] === 'SKIP';
|
||||
}
|
||||
|
||||
public function getUnmappedGroupIds(array $sourceGroupIds): array
|
||||
{
|
||||
return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? []));
|
||||
}
|
||||
|
||||
public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void
|
||||
{
|
||||
$mapping = $this->group_mapping ?? [];
|
||||
$mapping[$sourceGroupId] = $targetGroupId;
|
||||
$this->group_mapping = $mapping;
|
||||
}
|
||||
|
||||
// NEW: Assignment restore outcomes
|
||||
public function getAssignmentRestoreOutcomes(): array
|
||||
{
|
||||
return $this->results['assignment_outcomes'] ?? [];
|
||||
}
|
||||
|
||||
public function getSuccessfulAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn($outcome) => $outcome['status'] === 'success'
|
||||
));
|
||||
}
|
||||
|
||||
public function getFailedAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn($outcome) => $outcome['status'] === 'failed'
|
||||
));
|
||||
}
|
||||
|
||||
public function getSkippedAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn($outcome) => $outcome['status'] === 'skipped'
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Policy Model (Extensions)
|
||||
|
||||
**File**: `app/Models/Policy.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Policy extends Model
|
||||
{
|
||||
// Existing code...
|
||||
|
||||
// NEW: Assignment relationship (virtual, data comes from Graph API)
|
||||
public function getAssignmentsAttribute(): ?array
|
||||
{
|
||||
// This is fetched on-demand from Graph API, not stored in DB
|
||||
// Cached for 5 minutes to reduce API calls
|
||||
return Cache::remember("policy_assignments:{$this->id}", 300, function () {
|
||||
return app(AssignmentFetcher::class)->fetch($this->tenant_id, $this->graph_id);
|
||||
});
|
||||
}
|
||||
|
||||
public function hasAssignments(): bool
|
||||
{
|
||||
return !empty($this->assignments);
|
||||
}
|
||||
|
||||
// NEW: Scope for policies that support assignments
|
||||
public function scopeSupportsAssignments($query)
|
||||
{
|
||||
// Only Settings Catalog policies support assignments in Phase 1
|
||||
return $query->where('type', 'settingsCatalogPolicy');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Data Structures
|
||||
|
||||
### AssignmentFetcher Service
|
||||
|
||||
**Output Structure**:
|
||||
```php
|
||||
[
|
||||
[
|
||||
'id' => 'abc-123-def',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-abc-123',
|
||||
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
'settings' => null,
|
||||
'source' => 'direct',
|
||||
],
|
||||
// ... more assignments
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GroupResolver Service
|
||||
|
||||
**Input**: Array of group IDs
|
||||
**Output**:
|
||||
```php
|
||||
[
|
||||
'group-abc-123' => [
|
||||
'id' => 'group-abc-123',
|
||||
'displayName' => 'All Users',
|
||||
'orphaned' => false,
|
||||
],
|
||||
'group-def-456' => [
|
||||
'id' => 'group-def-456',
|
||||
'displayName' => null,
|
||||
'orphaned' => true, // Group doesn't exist in tenant
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ScopeTagResolver Service
|
||||
|
||||
**Input**: Array of scope tag IDs
|
||||
**Output**:
|
||||
```php
|
||||
[
|
||||
[
|
||||
'id' => '0',
|
||||
'displayName' => 'Default',
|
||||
],
|
||||
[
|
||||
'id' => 'abc-123-def',
|
||||
'displayName' => 'HR-Admins',
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### AssignmentRestoreService
|
||||
|
||||
**Input**: Policy ID, assignments array, group mapping
|
||||
**Output**:
|
||||
```php
|
||||
[
|
||||
[
|
||||
'status' => 'success',
|
||||
'assignment' => [...],
|
||||
'assignment_id' => 'new-abc-123',
|
||||
],
|
||||
[
|
||||
'status' => 'failed',
|
||||
'assignment' => [...],
|
||||
'error' => 'Group not found: xyz-789',
|
||||
'request_id' => 'abc-def-ghi',
|
||||
],
|
||||
[
|
||||
'status' => 'skipped',
|
||||
'assignment' => [...],
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Log Entries
|
||||
|
||||
### New Action Types
|
||||
|
||||
**File**: `config/audit_log_actions.php` (if exists, or add to model)
|
||||
|
||||
```php
|
||||
return [
|
||||
// Existing actions...
|
||||
|
||||
// NEW: Assignment backup/restore actions
|
||||
'backup.assignments.included' => 'Backup created with assignments',
|
||||
'backup.assignments.fetch_failed' => 'Failed to fetch assignments during backup',
|
||||
|
||||
'restore.group_mapping.applied' => 'Group mapping applied during restore',
|
||||
'restore.assignment.created' => 'Assignment created during restore',
|
||||
'restore.assignment.failed' => 'Assignment failed to restore',
|
||||
'restore.assignment.skipped' => 'Assignment skipped (group mapping)',
|
||||
|
||||
'policy.assignments.viewed' => 'Policy assignments viewed',
|
||||
];
|
||||
```
|
||||
|
||||
### Example Audit Log Entries
|
||||
|
||||
```php
|
||||
// Backup with assignments
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup.assignments.included',
|
||||
'resource_type' => 'backup_set',
|
||||
'resource_id' => $backupSet->id,
|
||||
'metadata' => [
|
||||
'policy_count' => 15,
|
||||
'assignment_count' => 47,
|
||||
],
|
||||
]);
|
||||
|
||||
// Group mapping applied
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'tenant_id' => $targetTenant->id,
|
||||
'action' => 'restore.group_mapping.applied',
|
||||
'resource_type' => 'restore_run',
|
||||
'resource_id' => $restoreRun->id,
|
||||
'metadata' => [
|
||||
'source_tenant_id' => $sourceTenant->id,
|
||||
'group_mapping' => $restoreRun->group_mapping,
|
||||
'mapped_count' => 5,
|
||||
'skipped_count' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Assignment created
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'tenant_id' => $targetTenant->id,
|
||||
'action' => 'restore.assignment.created',
|
||||
'resource_type' => 'assignment',
|
||||
'resource_id' => $assignmentId,
|
||||
'metadata' => [
|
||||
'policy_id' => $policyId,
|
||||
'target_group_id' => $targetGroupId,
|
||||
'intent' => 'apply',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL Indexes
|
||||
|
||||
### Recommended Indexes
|
||||
|
||||
```sql
|
||||
-- GIN index for JSONB assignment queries (optional, for analytics)
|
||||
CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments);
|
||||
|
||||
-- Index for filtering policies with assignments
|
||||
CREATE INDEX backup_items_assignments_not_null
|
||||
ON backup_items (id)
|
||||
WHERE assignments IS NOT NULL;
|
||||
|
||||
-- Index for restore runs with group mapping
|
||||
CREATE INDEX restore_runs_group_mapping_not_null
|
||||
ON restore_runs (id)
|
||||
WHERE group_mapping IS NOT NULL;
|
||||
|
||||
-- Composite index for tenant + resource type queries
|
||||
CREATE INDEX backup_items_tenant_type_idx
|
||||
ON backup_items (tenant_id, resource_type, created_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Size Estimates
|
||||
|
||||
### Storage Impact
|
||||
|
||||
| Entity | Existing Size | Assignment Data | Total Size | Growth |
|
||||
|--------|--------------|----------------|-----------|--------|
|
||||
| `backup_items` (1 policy) | ~10-50 KB | ~2-5 KB | ~12-55 KB | +20-40% |
|
||||
| `backup_items` (100 policies) | ~1-5 MB | ~200-500 KB | ~1.2-5.5 MB | +20-40% |
|
||||
| `restore_runs` (with mapping) | ~5-10 KB | ~1-2 KB | ~6-12 KB | +20% |
|
||||
|
||||
**Rationale**: Assignments are relatively small JSON objects. Even policies with 20+ assignments stay under 10 KB for assignment data.
|
||||
|
||||
---
|
||||
|
||||
## Migration Rollback Strategy
|
||||
|
||||
### If Rollback Needed
|
||||
|
||||
```php
|
||||
// Rollback Migration 1
|
||||
php artisan migrate:rollback --step=1
|
||||
// Drops `backup_items.assignments` column
|
||||
|
||||
// Rollback Migration 2
|
||||
php artisan migrate:rollback --step=1
|
||||
// Drops `restore_runs.group_mapping` column
|
||||
```
|
||||
|
||||
**Data Loss**: Rolling back will lose all stored assignments and group mappings. Backups can be re-created with assignments after rolling forward again.
|
||||
|
||||
**Safe Rollback**: Since assignments are optional (controlled by checkbox), existing backups without assignments remain functional.
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### BackupItem Validation
|
||||
|
||||
```php
|
||||
// In BackupItem model or Form Request
|
||||
public static function assignmentsValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'assignments' => ['nullable', 'array'],
|
||||
'assignments.*.id' => ['required', 'string'],
|
||||
'assignments.*.target' => ['required', 'array'],
|
||||
'assignments.*.target.@odata.type' => ['required', 'string'],
|
||||
'assignments.*.target.groupId' => ['required_if:assignments.*.target.@odata.type,#microsoft.graph.groupAssignmentTarget', 'string'],
|
||||
'assignments.*.intent' => ['required', 'string', 'in:apply,exclude'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### RestoreRun Group Mapping Validation
|
||||
|
||||
```php
|
||||
// In RestoreRun model or Form Request
|
||||
public static function groupMappingValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'group_mapping' => ['nullable', 'array'],
|
||||
'group_mapping.*' => ['string'], // Target group ID or "SKIP"
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Database Changes
|
||||
- ✅ `backup_items.assignments` (JSONB, nullable)
|
||||
- ✅ `backup_items.metadata` (extend with assignment summary)
|
||||
- ✅ `restore_runs.group_mapping` (JSONB, nullable)
|
||||
|
||||
### Model Enhancements
|
||||
- ✅ `BackupItem`: Assignment accessors, scopes, helpers
|
||||
- ✅ `RestoreRun`: Group mapping helpers, outcome methods
|
||||
- ✅ `Policy`: Virtual assignments relationship (cached)
|
||||
|
||||
### Indexes
|
||||
- ✅ GIN index on `backup_items.assignments` (optional)
|
||||
- ✅ Partial indexes for non-null checks
|
||||
|
||||
### Data Structures
|
||||
- ✅ Assignment JSON schema defined
|
||||
- ✅ Group mapping JSON schema defined
|
||||
- ✅ Audit log action types defined
|
||||
|
||||
---
|
||||
|
||||
**Status**: Data Model Complete
|
||||
**Next Document**: `quickstart.md`
|
||||
461
specs/004-assignments-scope-tags/plan.md
Normal file
461
specs/004-assignments-scope-tags/plan.md
Normal file
@ -0,0 +1,461 @@
|
||||
# Feature 004: Assignments & Scope Tags - Implementation Plan
|
||||
|
||||
## Project Context
|
||||
|
||||
### Technical Foundation
|
||||
- **Laravel**: 12 (latest stable)
|
||||
- **PHP**: 8.4.15
|
||||
- **Admin UI**: Filament v4
|
||||
- **Interactive Components**: Livewire v3
|
||||
- **Database**: PostgreSQL with JSONB
|
||||
- **External API**: Microsoft Graph API (Intune endpoints)
|
||||
- **Testing**: Pest v4 (unit, feature, browser tests)
|
||||
- **Local Dev**: Laravel Sail (Docker)
|
||||
- **Deployment**: Dokploy (VPS, staging + production)
|
||||
|
||||
### Constitution Check
|
||||
✅ Spec reviewed: `specs/004-assignments-scope-tags/spec.md`
|
||||
✅ Constitution followed: `.specify/constitution.md`
|
||||
✅ SDD workflow: Feature branch → spec + code → PR to dev
|
||||
✅ Multi-agent coordination: Session branch pattern recommended
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
app/
|
||||
├── Models/
|
||||
│ ├── BackupItem.php # Add assignments column
|
||||
│ ├── RestoreRun.php # Add group_mapping column
|
||||
│ └── Policy.php # Add assignments relationship methods
|
||||
├── Services/
|
||||
│ ├── Graph/
|
||||
│ │ ├── AssignmentFetcher.php # NEW: Fetch assignments with fallback
|
||||
│ │ ├── GroupResolver.php # NEW: Resolve group IDs to names
|
||||
│ │ └── ScopeTagResolver.php # NEW: Resolve scope tag IDs with cache
|
||||
│ ├── AssignmentBackupService.php # NEW: Backup assignments logic
|
||||
│ └── AssignmentRestoreService.php # NEW: Restore assignments logic
|
||||
├── Filament/
|
||||
│ ├── Resources/
|
||||
│ │ └── PolicyResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewPolicy.php # Add Assignments tab
|
||||
│ └── Forms/Components/
|
||||
│ └── GroupMappingWizard.php # NEW: Multi-step group mapping
|
||||
└── Jobs/
|
||||
├── FetchAssignmentsJob.php # NEW: Async assignment fetch
|
||||
└── RestoreAssignmentsJob.php # NEW: Async assignment restore
|
||||
|
||||
database/migrations/
|
||||
├── xxxx_add_assignments_to_backup_items.php
|
||||
└── xxxx_add_group_mapping_to_restore_runs.php
|
||||
|
||||
tests/
|
||||
├── Unit/
|
||||
│ ├── AssignmentFetcherTest.php
|
||||
│ ├── GroupResolverTest.php
|
||||
│ └── ScopeTagResolverTest.php
|
||||
├── Feature/
|
||||
│ ├── BackupWithAssignmentsTest.php
|
||||
│ ├── PolicyViewAssignmentsTabTest.php
|
||||
│ ├── RestoreGroupMappingTest.php
|
||||
│ └── RestoreAssignmentApplicationTest.php
|
||||
└── Browser/
|
||||
└── GroupMappingWizardTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Setup & Database (Foundation)
|
||||
**Duration**: 2-3 hours
|
||||
**Goal**: Prepare data layer for storing assignments and group mappings
|
||||
|
||||
**Tasks**:
|
||||
1. Create migration for `backup_items.assignments` JSONB column
|
||||
2. Create migration for `restore_runs.group_mapping` JSONB column
|
||||
3. Update `BackupItem` model with `assignments` cast and accessor methods
|
||||
4. Update `RestoreRun` model with `group_mapping` cast and helper methods
|
||||
5. Add Graph contract config for assignments endpoints in `config/graph_contracts.php`
|
||||
6. Create unit tests for model methods
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Migrations reversible and run cleanly on Sail
|
||||
- Models have proper JSONB casts
|
||||
- Unit tests pass for assignment/mapping accessors
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Graph API Integration (Core Services)
|
||||
**Duration**: 4-6 hours
|
||||
**Goal**: Build reliable services to fetch/resolve assignments and scope tags
|
||||
|
||||
**Tasks**:
|
||||
1. Create `AssignmentFetcher` service with fallback strategy:
|
||||
- Primary: GET `/assignments`
|
||||
- Fallback: GET with `$expand=assignments`
|
||||
- Error handling with fail-soft
|
||||
2. Create `GroupResolver` service:
|
||||
- POST `/directoryObjects/getByIds` batch resolution
|
||||
- Handle orphaned IDs gracefully
|
||||
- Cache resolved groups (5 min TTL)
|
||||
3. Create `ScopeTagResolver` service:
|
||||
- GET `/deviceManagement/roleScopeTags`
|
||||
- Cache scope tags (1 hour TTL)
|
||||
- Extract from policy payload's `roleScopeTagIds`
|
||||
4. Write unit tests mocking Graph responses:
|
||||
- Success scenarios
|
||||
- Partial failures (some IDs not found)
|
||||
- Complete failures (API down)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Services handle all failure scenarios gracefully
|
||||
- Tests achieve 90%+ coverage
|
||||
- Fallback strategies proven with mocks
|
||||
- Cache TTLs configurable via config
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: US1 - Backup with Assignments (MVP Core)
|
||||
**Duration**: 4-5 hours
|
||||
**Goal**: Allow admins to optionally include assignments in backups
|
||||
|
||||
**Tasks**:
|
||||
1. Add "Include Assignments & Scope Tags" checkbox to Backup creation form
|
||||
2. Create `AssignmentBackupService`:
|
||||
- Accept tenantId, policyId, includeAssignments flag
|
||||
- Call `AssignmentFetcher` if flag enabled
|
||||
- Resolve scope tag names via `ScopeTagResolver`
|
||||
- Update `backup_items.metadata` with assignment count
|
||||
- Store assignments in `backup_items.assignments` column
|
||||
3. Dispatch async job `FetchAssignmentsJob` if checkbox enabled
|
||||
4. Handle job failures: log warning, set `assignments_fetch_failed: true`
|
||||
5. Create feature test: `BackupWithAssignmentsTest`
|
||||
- Test backup with checkbox enabled
|
||||
- Test backup with checkbox disabled
|
||||
- Test assignment fetch failure handling
|
||||
6. Add audit log entry: `backup.assignments.included`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Checkbox appears on Settings Catalog backup forms only
|
||||
- Assignments stored in JSONB with correct schema
|
||||
- Metadata includes `assignment_count`, `scope_tag_ids`, `has_orphaned_assignments`
|
||||
- Feature test passes with mocked Graph responses
|
||||
- Audit log records decision
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: US2 - Policy View with Assignments Tab
|
||||
**Duration**: 3-4 hours
|
||||
**Goal**: Display assignments in read-only view for auditing
|
||||
|
||||
**Tasks**:
|
||||
1. Add "Assignments" tab to `PolicyResource/ViewPolicy.php`
|
||||
2. Create Filament Table for assignments:
|
||||
- Columns: Type, Name, Mode (Include/Exclude), ID
|
||||
- Handle orphaned IDs: "Unknown Group (ID: {id})" with warning icon
|
||||
3. Create Scope Tags section (list with IDs)
|
||||
4. Handle empty state: "No assignments found"
|
||||
5. Update `BackupItem` detail view to show assignment summary in metadata card
|
||||
6. Create feature test: `PolicyViewAssignmentsTabTest`
|
||||
- Test assignments table rendering
|
||||
- Test orphaned ID display
|
||||
- Test scope tags section
|
||||
- Test empty state
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Tab visible only for Settings Catalog policies with assignments
|
||||
- Orphaned IDs render with clear warning
|
||||
- Scope tags display with names + IDs
|
||||
- Feature test passes
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: US3 - Restore with Group Mapping (Core Restore)
|
||||
**Duration**: 6-8 hours
|
||||
**Goal**: Enable cross-tenant restores with group mapping wizard
|
||||
|
||||
**Tasks**:
|
||||
1. Create `GroupMappingWizard` Filament multi-step form component:
|
||||
- Step 1: Restore Preview (existing)
|
||||
- Step 2: Group Mapping (NEW)
|
||||
- Step 3: Confirm (existing)
|
||||
2. Implement Group Mapping step logic:
|
||||
- Detect unresolved groups via POST `/directoryObjects/getByIds`
|
||||
- Fetch target tenant groups (with caching, 5 min TTL)
|
||||
- Render table: Source Group | Target Group Dropdown | Skip Checkbox
|
||||
- Search/filter support for 500+ groups
|
||||
3. Persist group mapping in `restore_runs.group_mapping` JSON
|
||||
4. Create `AssignmentRestoreService`:
|
||||
- Accept policyId, assignments array, group_mapping object
|
||||
- Replace source group IDs with mapped target IDs
|
||||
- Skip assignments marked "Skip"
|
||||
- Execute DELETE-then-CREATE pattern:
|
||||
* GET existing assignments
|
||||
* DELETE each (204 No Content expected)
|
||||
* POST each new/mapped assignment (201 Created expected)
|
||||
- Handle per-assignment failures (fail-soft)
|
||||
- Log outcomes per assignment
|
||||
5. Dispatch async job `RestoreAssignmentsJob`
|
||||
6. Create feature test: `RestoreGroupMappingTest`
|
||||
- Test group mapping wizard flow
|
||||
- Test group ID resolution
|
||||
- Test mapping persistence
|
||||
- Test skip functionality
|
||||
7. Add audit log entries:
|
||||
- `restore.group_mapping.applied`
|
||||
- `restore.assignment.created` (per assignment)
|
||||
- `restore.assignment.skipped`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Group mapping step appears only when unresolved groups exist
|
||||
- Dropdown searchable with 500+ groups
|
||||
- Mapping persisted and visible in audit logs
|
||||
- Restore applies assignments correctly with mapped IDs
|
||||
- Per-assignment outcomes logged
|
||||
- Feature test passes
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: US4 - Restore Preview with Assignment Diff
|
||||
**Duration**: 2-3 hours
|
||||
**Goal**: Show admins what assignments will change before restore
|
||||
|
||||
**Tasks**:
|
||||
1. Enhance Restore Preview step to show assignment diff:
|
||||
- Added assignments (green)
|
||||
- Removed assignments (red)
|
||||
- Unchanged assignments (gray)
|
||||
2. Add scope tag diff: "Scope Tags: 2 matched, 1 not found in target"
|
||||
3. Create diff algorithm:
|
||||
- Compare source assignments with target policy's current assignments
|
||||
- Group by change type (added/removed/unchanged)
|
||||
4. Update feature test: `RestoreAssignmentApplicationTest` to verify diff display
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Diff shows clear visual indicators (colors, icons)
|
||||
- Scope tag warnings visible
|
||||
- Diff accurate for all scenarios (same tenant, cross-tenant, empty target)
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Scope Tags (Full Support)
|
||||
**Duration**: 2-3 hours
|
||||
**Goal**: Complete scope tag handling in backup/restore
|
||||
|
||||
**Tasks**:
|
||||
1. Update `AssignmentBackupService` to extract `roleScopeTagIds` from policy payload
|
||||
2. Resolve scope tag names via `ScopeTagResolver` during backup
|
||||
3. Update restore logic to preserve scope tag IDs if they exist in target
|
||||
4. Log warnings for missing scope tags (don't block restore)
|
||||
5. Update unit test: `ScopeTagResolverTest`
|
||||
6. Update feature test: Add scope tag scenarios to `RestoreAssignmentApplicationTest`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Scope tags captured during backup with names
|
||||
- Restore preserves IDs when available in target
|
||||
- Warnings logged for missing scope tags
|
||||
- Tests pass with various scope tag scenarios
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Polish & Performance
|
||||
**Duration**: 3-4 hours
|
||||
**Goal**: Optimize performance and improve UX
|
||||
|
||||
**Tasks**:
|
||||
1. Add loading indicators to Group Mapping dropdown (wire:loading)
|
||||
2. Implement group search debouncing (500ms)
|
||||
3. Optimize Graph API calls:
|
||||
- Batch group resolution (max 100 IDs per batch)
|
||||
- Add 100ms delay between sequential assignment POST calls
|
||||
4. Add cache warming for target tenant groups
|
||||
5. Create performance test: Restore 50 policies with 10 assignments each
|
||||
6. Add tooltips/help text:
|
||||
- Backup checkbox: "Captures group/user targeting and RBAC scope. Adds ~2-5 KB per policy."
|
||||
- Group Mapping: "Map source groups to target groups for cross-tenant migrations."
|
||||
7. Update documentation: Add "Assignments & Scope Tags" section to README
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Loading states visible during async operations
|
||||
- Search responsive (no lag with 500+ groups)
|
||||
- Performance benchmarks documented
|
||||
- Tooltips clear and helpful
|
||||
- README updated
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Testing & QA
|
||||
**Duration**: 2-3 hours
|
||||
**Goal**: Comprehensive testing across all scenarios
|
||||
|
||||
**Tasks**:
|
||||
1. Manual QA checklist:
|
||||
- ✅ Create backup with assignments checkbox (same tenant)
|
||||
- ✅ Create backup without assignments checkbox
|
||||
- ✅ View policy with assignments tab
|
||||
- ✅ Restore to same tenant (auto-match groups)
|
||||
- ✅ Restore to different tenant (group mapping wizard)
|
||||
- ✅ Handle orphaned group IDs gracefully
|
||||
- ✅ Skip assignments during group mapping
|
||||
- ✅ Handle Graph API failures (assignments fetch, group resolution)
|
||||
2. Browser test: `GroupMappingWizardTest`
|
||||
- Navigate through multi-step wizard
|
||||
- Search groups in dropdown
|
||||
- Toggle skip checkboxes
|
||||
- Verify mapping persistence
|
||||
3. Load testing: 100+ policies with 20 assignments each
|
||||
4. Staging deployment validation
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- All manual QA scenarios pass
|
||||
- Browser test passes on Chrome/Firefox
|
||||
- Load test completes under 5 minutes
|
||||
- Staging environment stable
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Deployment & Documentation
|
||||
**Duration**: 1-2 hours
|
||||
**Goal**: Production-ready deployment
|
||||
|
||||
**Tasks**:
|
||||
1. Create deployment checklist:
|
||||
- Run migrations on staging
|
||||
- Verify no data loss
|
||||
- Test on production-like data volume
|
||||
2. Update `.specify/spec.md` with implementation notes
|
||||
3. Create migration guide for existing backups (no retroactive assignment capture)
|
||||
4. Add monitoring alerts:
|
||||
- Assignment fetch failure rate > 10%
|
||||
- Group resolution failure rate > 5%
|
||||
5. Production deployment:
|
||||
- Deploy to production via Dokploy
|
||||
- Monitor logs for 24 hours
|
||||
- Verify no Graph API rate limit issues
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Production deployment successful
|
||||
- No critical errors in 24-hour monitoring window
|
||||
- Documentation complete and accurate
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Hard Dependencies (Required)
|
||||
- Feature 001: Backup/Restore core infrastructure ✅
|
||||
- Graph Contract Registry ✅
|
||||
- Filament multi-step forms (built-in) ✅
|
||||
|
||||
### Soft Dependencies (Nice to Have)
|
||||
- Feature 005: Bulk Operations (for bulk assignment backup) 🚧
|
||||
|
||||
---
|
||||
|
||||
## Risk Management
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|-----------|
|
||||
| Graph API assignments endpoint slow | Medium | Medium | Async fetch with fail-soft, cache groups |
|
||||
| Target tenant has 1000+ groups | High | Medium | Searchable dropdown with pagination, cache |
|
||||
| Group IDs change across tenants | High | High | Group name-based fallback matching (future) |
|
||||
| Scope Tag IDs don't exist in target | Medium | Low | Log warning, allow policy creation |
|
||||
| Assignment restore fails mid-process | Medium | High | Per-assignment error handling, audit log |
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Functional
|
||||
- ✅ Backup checkbox functional, assignments captured
|
||||
- ✅ Policy View shows assignments tab with accurate data
|
||||
- ✅ Group Mapping wizard handles 100+ groups smoothly
|
||||
- ✅ Restore applies assignments with 90%+ success rate
|
||||
- ✅ Audit logs record all mapping decisions
|
||||
|
||||
### Technical
|
||||
- ✅ Tests achieve 85%+ coverage for new code
|
||||
- ✅ Graph API calls < 2 seconds average
|
||||
- ✅ Group mapping UI responsive with 500+ groups
|
||||
- ✅ Assignment restore completes in < 30 seconds for 20 policies
|
||||
|
||||
### User Experience
|
||||
- ✅ Admins can backup/restore assignments without manual intervention
|
||||
- ✅ Cross-tenant migrations supported with clear group mapping UX
|
||||
- ✅ Orphaned IDs handled gracefully with clear warnings
|
||||
|
||||
---
|
||||
|
||||
## MVP Scope (Phases 1-4 + Core of Phase 5)
|
||||
|
||||
**Estimated Duration**: 16-22 hours
|
||||
|
||||
**Included**:
|
||||
- US1: Backup with Assignments checkbox ✅
|
||||
- US2: Policy View with Assignments tab ✅
|
||||
- US3: Restore with Group Mapping (basic, same-tenant auto-match) ✅
|
||||
|
||||
**Excluded** (Post-MVP):
|
||||
- US4: Restore Preview with detailed diff
|
||||
- Scope Tags full support
|
||||
- Performance optimizations
|
||||
- Cross-tenant group mapping wizard
|
||||
|
||||
**Rationale**: MVP proves core value (backup/restore assignments) while deferring complex cross-tenant mapping for iteration.
|
||||
|
||||
---
|
||||
|
||||
## Full Implementation Estimate
|
||||
|
||||
**Total Duration**: 30-40 hours
|
||||
|
||||
**Phase Breakdown**:
|
||||
- Phase 1: Setup & Database (2-3 hours)
|
||||
- Phase 2: Graph API Integration (4-6 hours)
|
||||
- Phase 3: US1 - Backup (4-5 hours)
|
||||
- Phase 4: US2 - Policy View (3-4 hours)
|
||||
- Phase 5: US3 - Group Mapping (6-8 hours)
|
||||
- Phase 6: US4 - Restore Preview (2-3 hours)
|
||||
- Phase 7: Scope Tags (2-3 hours)
|
||||
- Phase 8: Polish (3-4 hours)
|
||||
- Phase 9: Testing & QA (2-3 hours)
|
||||
- Phase 10: Deployment (1-2 hours)
|
||||
|
||||
**Parallel Opportunities**:
|
||||
- Phase 2 (Graph services) + Phase 3 (Backup) can be split across developers
|
||||
- Phase 4 (Policy View) independent of Phase 5 (Restore)
|
||||
- Phase 7 (Scope Tags) can be developed alongside Phase 6 (Restore Preview)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (For Review)
|
||||
|
||||
1. **Smart Matching**: Should we support "smart matching" (group name similarity) for group mapping?
|
||||
- **Recommendation**: Phase 2 feature, start with manual mapping MVP
|
||||
|
||||
2. **Dynamic Groups**: How to handle dynamic groups (membership rules) - copy rules or skip?
|
||||
- **Recommendation**: Skip initially, document as limitation, consider for future
|
||||
|
||||
3. **Scope Tag Blocking**: Should Scope Tag warnings block restore or just warn?
|
||||
- **Recommendation**: Warn only, allow restore to proceed (Graph API default behavior)
|
||||
|
||||
4. **Assignment Filters**: Should we preserve assignment filters (device/user filters)?
|
||||
- **Recommendation**: Yes, preserve all filter properties in JSON
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review**: Team review of plan.md, resolve open questions
|
||||
2. **Research**: Create `research.md` with detailed technology decisions
|
||||
3. **Data Model**: Create `data-model.md` with schema details
|
||||
4. **Quickstart**: Create `quickstart.md` with developer setup
|
||||
5. **Tasks**: Break down phases into granular tasks in `tasks.md`
|
||||
6. **Implement**: Start with Phase 1 (Setup & Database)
|
||||
|
||||
---
|
||||
|
||||
**Status**: Ready for Review
|
||||
**Created**: 2025-12-22
|
||||
**Estimated Total Duration**: 30-40 hours (MVP: 16-22 hours)
|
||||
**Next Document**: `research.md`
|
||||
616
specs/004-assignments-scope-tags/quickstart.md
Normal file
616
specs/004-assignments-scope-tags/quickstart.md
Normal file
@ -0,0 +1,616 @@
|
||||
# Feature 004: Assignments & Scope Tags - Developer Quickstart
|
||||
|
||||
## Overview
|
||||
This guide helps developers quickly set up their environment and start working on the Assignments & Scope Tags feature.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Laravel Sail** installed and running
|
||||
- **PostgreSQL** database via Sail
|
||||
- **Microsoft Graph API** test tenant credentials
|
||||
- **Redis** (optional, for caching - Sail includes it)
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### 1. Start Sail Environment
|
||||
|
||||
```bash
|
||||
# Start all containers
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
# Check status
|
||||
./vendor/bin/sail ps
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
|
||||
```bash
|
||||
# Run new migrations for assignments
|
||||
./vendor/bin/sail artisan migrate
|
||||
|
||||
# Verify new columns exist
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> Schema::hasColumn('backup_items', 'assignments')
|
||||
=> true
|
||||
>>> Schema::hasColumn('restore_runs', 'group_mapping')
|
||||
=> true
|
||||
```
|
||||
|
||||
### 3. Seed Test Data (Optional)
|
||||
|
||||
```bash
|
||||
# Seed tenants, policies, and backup sets
|
||||
./vendor/bin/sail artisan db:seed
|
||||
|
||||
# Or create specific test data
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $tenant = Tenant::factory()->create(['name' => 'Test Tenant']);
|
||||
>>> $policy = Policy::factory()->for($tenant)->create(['type' => 'settingsCatalogPolicy']);
|
||||
>>> $backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
```
|
||||
|
||||
### 4. Configure Graph API Credentials
|
||||
|
||||
```bash
|
||||
# Copy example env
|
||||
cp .env.example .env.testing
|
||||
|
||||
# Add Graph API credentials
|
||||
GRAPH_CLIENT_ID=your-client-id
|
||||
GRAPH_CLIENT_SECRET=your-client-secret
|
||||
GRAPH_TENANT_ID=your-test-tenant-id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# Run specific test file
|
||||
./vendor/bin/sail artisan test tests/Feature/BackupWithAssignmentsTest.php
|
||||
|
||||
# Run tests with filter
|
||||
./vendor/bin/sail artisan test --filter=assignment
|
||||
|
||||
# Run tests with coverage
|
||||
./vendor/bin/sail artisan test --coverage
|
||||
```
|
||||
|
||||
### Using Tinker for Debugging
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
```
|
||||
|
||||
**Common Tinker Commands**:
|
||||
|
||||
```php
|
||||
// Fetch assignments for a policy
|
||||
$fetcher = app(AssignmentFetcher::class);
|
||||
$assignments = $fetcher->fetch('tenant-id', 'policy-graph-id');
|
||||
dump($assignments);
|
||||
|
||||
// Test group resolution
|
||||
$resolver = app(GroupResolver::class);
|
||||
$groups = $resolver->resolveGroupIds(['group-1', 'group-2'], 'tenant-id');
|
||||
dump($groups);
|
||||
|
||||
// Test backup with assignments
|
||||
$service = app(AssignmentBackupService::class);
|
||||
$backupItem = $service->backup(
|
||||
tenantId: 1,
|
||||
policyId: 'abc-123',
|
||||
includeAssignments: true
|
||||
);
|
||||
dump($backupItem->assignments);
|
||||
|
||||
// Test group mapping
|
||||
$restoreRun = RestoreRun::first();
|
||||
$restoreRun->addGroupMapping('source-group-1', 'target-group-1');
|
||||
$restoreRun->save();
|
||||
dump($restoreRun->group_mapping);
|
||||
|
||||
// Clear cache
|
||||
Cache::flush();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Scenarios
|
||||
|
||||
### Scenario 1: Backup with Assignments (Happy Path)
|
||||
|
||||
**Goal**: Verify assignments are captured during backup
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to Backup creation form: `/tenants/{tenant}/backups/create`
|
||||
2. Select Settings Catalog policies
|
||||
3. ✅ Check "Include Assignments & Scope Tags"
|
||||
4. Click "Create Backup"
|
||||
5. Wait for backup job to complete
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $backupItem = BackupItem::latest()->first();
|
||||
>>> dump($backupItem->assignments);
|
||||
>>> dump($backupItem->metadata['assignment_count']);
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- `assignments` column populated with JSON array
|
||||
- `metadata['assignment_count']` matches actual count
|
||||
- Audit log entry: `backup.assignments.included`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Policy View with Assignments Tab
|
||||
|
||||
**Goal**: Verify assignments display correctly in UI
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to Policy view: `/policies/{policy}`
|
||||
2. Click "Assignments" tab
|
||||
|
||||
**Verification**:
|
||||
- Table shows assignments (Type, Name, Mode, ID)
|
||||
- Orphaned IDs render as "Unknown Group (ID: {id})" with warning icon
|
||||
- Scope Tags section shows tag names + IDs
|
||||
- Empty state if no assignments
|
||||
|
||||
**Edge Cases**:
|
||||
- Policy with 0 assignments → Empty state
|
||||
- Policy with orphaned group ID → Warning icon
|
||||
- Policy without assignments metadata → Empty state
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Restore with Group Mapping (Cross-Tenant)
|
||||
|
||||
**Goal**: Verify group mapping wizard works for cross-tenant restore
|
||||
|
||||
**Setup**:
|
||||
```bash
|
||||
# Create source and target tenants
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $sourceTenant = Tenant::factory()->create(['name' => 'Source Tenant']);
|
||||
>>> $targetTenant = Tenant::factory()->create(['name' => 'Target Tenant']);
|
||||
|
||||
# Create groups in both tenants (simulate via test data)
|
||||
>>> Group::factory()->for($sourceTenant)->create(['graph_id' => 'source-group-1', 'display_name' => 'HR Team']);
|
||||
>>> Group::factory()->for($targetTenant)->create(['graph_id' => 'target-group-1', 'display_name' => 'HR Department']);
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to Restore wizard: `/backups/{backup}/restore`
|
||||
2. Select target tenant (different from source)
|
||||
3. Click "Continue" to Restore Preview
|
||||
4. **Group Mapping step should appear**:
|
||||
- Source group: "HR Team (source-group-1)"
|
||||
- Target group dropdown: searchable, populated with target tenant groups
|
||||
- Select "HR Department" (target-group-1)
|
||||
5. Click "Continue"
|
||||
6. Review Restore Preview (should show mapped group)
|
||||
7. Click "Restore"
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $restoreRun = RestoreRun::latest()->first();
|
||||
>>> dump($restoreRun->group_mapping);
|
||||
=> ["source-group-1" => "target-group-1"]
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- Group mapping step only appears when unresolved groups detected
|
||||
- Dropdown searchable with 500+ groups
|
||||
- Mapping persisted in `restore_runs.group_mapping`
|
||||
- Audit log entries: `restore.group_mapping.applied`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Handle Graph API Failures
|
||||
|
||||
**Goal**: Verify fail-soft behavior when Graph API fails
|
||||
|
||||
**Mock Graph Failure**:
|
||||
```php
|
||||
// In tests or local dev with Http::fake()
|
||||
Http::fake([
|
||||
'*/assignments' => Http::response(null, 500), // Simulate failure
|
||||
]);
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
1. Create backup with "Include Assignments" checkbox
|
||||
2. Observe behavior
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $backupItem = BackupItem::latest()->first();
|
||||
>>> dump($backupItem->metadata['assignments_fetch_failed']);
|
||||
=> true
|
||||
>>> dump($backupItem->assignments);
|
||||
=> null
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- Backup completes successfully (fail-soft)
|
||||
- `assignments_fetch_failed` flag set to `true`
|
||||
- Warning logged in `storage/logs/laravel.log`
|
||||
- Audit log entry: `backup.assignments.fetch_failed`
|
||||
|
||||
---
|
||||
|
||||
### Scenario 5: Skip Assignments in Group Mapping
|
||||
|
||||
**Goal**: Verify "Skip" functionality in group mapping
|
||||
|
||||
**Steps**:
|
||||
1. Follow Scenario 3 setup
|
||||
2. In Group Mapping step:
|
||||
- Check "Skip" checkbox for one source group
|
||||
- Map other groups normally
|
||||
3. Complete restore
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $restoreRun = RestoreRun::latest()->first();
|
||||
>>> dump($restoreRun->group_mapping);
|
||||
=> ["source-group-1" => "target-group-1", "source-group-2" => "SKIP"]
|
||||
|
||||
>>> dump($restoreRun->getSkippedAssignmentsCount());
|
||||
=> 2 # Assignments targeting source-group-2 were skipped
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- Skipped group has `"SKIP"` value in mapping JSON
|
||||
- Restore runs without creating assignments for skipped groups
|
||||
- Audit log entries: `restore.assignment.skipped` (per skipped)
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue 1: Migrations Fail
|
||||
|
||||
**Error**: `SQLSTATE[42P01]: Undefined table: backup_items`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Reset database and re-run migrations
|
||||
./vendor/bin/sail artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: Graph API Rate Limiting
|
||||
|
||||
**Error**: `429 Too Many Requests`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Add retry logic with exponential backoff (already in GraphClient)
|
||||
# Or reduce test load:
|
||||
# - Use Http::fake() in tests
|
||||
# - Add delays between API calls (100ms)
|
||||
```
|
||||
|
||||
**Check Rate Limit Headers**:
|
||||
```php
|
||||
// In GraphClient.php
|
||||
Log::info('Graph API call', [
|
||||
'endpoint' => $endpoint,
|
||||
'retry_after' => $response->header('Retry-After'),
|
||||
'remaining_calls' => $response->header('X-RateLimit-Remaining'),
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Cache Not Clearing
|
||||
|
||||
**Error**: Stale group names in UI after updating tenant
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Clear all cache
|
||||
./vendor/bin/sail artisan cache:clear
|
||||
|
||||
# Clear specific cache keys
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> Cache::forget('groups:tenant-id:*');
|
||||
>>> Cache::forget('scope_tags:all');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: Assignments Not Showing in UI
|
||||
|
||||
**Checklist**:
|
||||
1. ✅ Checkbox was enabled during backup?
|
||||
2. ✅ `backup_items.assignments` column has data? (check via tinker)
|
||||
3. ✅ Policy type is `settingsCatalogPolicy`? (others not supported in Phase 1)
|
||||
4. ✅ Graph API call succeeded? (check logs for errors)
|
||||
|
||||
**Debug**:
|
||||
```bash
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> $backupItem = BackupItem::find(123);
|
||||
>>> dump($backupItem->assignments);
|
||||
>>> dump($backupItem->hasAssignments());
|
||||
>>> dump($backupItem->metadata['assignments_fetch_failed']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 5: Group Mapping Dropdown Slow
|
||||
|
||||
**Symptom**: Dropdown takes 5+ seconds to load with 500+ groups
|
||||
|
||||
**Solutions**:
|
||||
1. **Increase cache TTL**:
|
||||
```php
|
||||
// In GroupResolver.php
|
||||
Cache::remember("groups:{$tenantId}", 600, function () { ... }); // 10 min
|
||||
```
|
||||
|
||||
2. **Pre-warm cache**:
|
||||
```php
|
||||
// In RestoreWizard.php mount()
|
||||
public function mount()
|
||||
{
|
||||
// Cache groups when wizard opens
|
||||
app(GroupResolver::class)->getAllForTenant($this->targetTenantId);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add debouncing**:
|
||||
```php
|
||||
// In Filament Select component
|
||||
->debounce(500) // Wait 500ms after typing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
### Target Metrics
|
||||
|
||||
| Operation | Target | Actual (Measure) |
|
||||
|-----------|--------|------------------|
|
||||
| Assignment fetch (per policy) | < 2s | ___ |
|
||||
| Group resolution (100 groups) | < 1s | ___ |
|
||||
| Group mapping UI search | < 500ms | ___ |
|
||||
| Assignment restore (20 policies) | < 30s | ___ |
|
||||
|
||||
### Measuring Performance
|
||||
|
||||
```bash
|
||||
# Enable query logging
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> DB::enableQueryLog();
|
||||
|
||||
# Run operation
|
||||
>>> $fetcher = app(AssignmentFetcher::class);
|
||||
>>> $start = microtime(true);
|
||||
>>> $assignments = $fetcher->fetch('tenant-id', 'policy-id');
|
||||
>>> $duration = microtime(true) - $start;
|
||||
>>> dump("Duration: {$duration}s");
|
||||
|
||||
# Check queries
|
||||
>>> dump(DB::getQueryLog());
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
```bash
|
||||
# Create 100 policies with assignments
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> for ($i = 0; $i < 100; $i++) {
|
||||
>>> $policy = Policy::factory()->create(['type' => 'settingsCatalogPolicy']);
|
||||
>>> $backupItem = BackupItem::factory()->for($policy->backupSet)->create([
|
||||
>>> 'assignments' => [/* 10 assignments */]
|
||||
>>> ]);
|
||||
>>> }
|
||||
|
||||
# Measure restore time
|
||||
>>> $start = microtime(true);
|
||||
>>> $service = app(AssignmentRestoreService::class);
|
||||
>>> $outcomes = $service->restoreBatch($policyIds, $groupMapping);
|
||||
>>> $duration = microtime(true) - $start;
|
||||
>>> dump("Restored 100 policies in {$duration}s");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### 1. Laravel Telescope (Optional)
|
||||
|
||||
```bash
|
||||
# Install Telescope (if not already)
|
||||
./vendor/bin/sail composer require laravel/telescope --dev
|
||||
./vendor/bin/sail artisan telescope:install
|
||||
./vendor/bin/sail artisan migrate
|
||||
|
||||
# Access Telescope
|
||||
# Navigate to: http://localhost/telescope
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
- Monitor Graph API calls (Requests tab)
|
||||
- Track slow queries (Queries tab)
|
||||
- View cache hits/misses (Cache tab)
|
||||
- Inspect jobs (Jobs tab)
|
||||
|
||||
---
|
||||
|
||||
### 2. Laravel Debugbar (Installed)
|
||||
|
||||
```bash
|
||||
# Ensure Debugbar is enabled
|
||||
DEBUGBAR_ENABLED=true # in .env
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Query count and duration per page load
|
||||
- View all HTTP requests (including Graph API)
|
||||
- Cache operations
|
||||
- Timeline of execution
|
||||
|
||||
---
|
||||
|
||||
### 3. Graph API Explorer
|
||||
|
||||
**URL**: https://developer.microsoft.com/en-us/graph/graph-explorer
|
||||
|
||||
**Use Cases**:
|
||||
- Test Graph API endpoints manually
|
||||
- Verify assignment response structure
|
||||
- Debug authentication issues
|
||||
- Check available permissions
|
||||
|
||||
**Example Queries**:
|
||||
```
|
||||
GET /deviceManagement/configurationPolicies/{id}/assignments
|
||||
POST /directoryObjects/getByIds
|
||||
GET /deviceManagement/roleScopeTags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Data Factories
|
||||
|
||||
### Factory: BackupItem with Assignments
|
||||
|
||||
```php
|
||||
// database/factories/BackupItemFactory.php
|
||||
public function withAssignments(int $count = 5): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'assignments' => collect(range(1, $count))->map(fn ($i) => [
|
||||
'id' => "assignment-{$i}",
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => "group-{$i}",
|
||||
],
|
||||
'intent' => 'apply',
|
||||
])->toArray(),
|
||||
'metadata' => array_merge($attributes['metadata'] ?? [], [
|
||||
'assignment_count' => $count,
|
||||
'scope_tag_ids' => ['0'],
|
||||
'scope_tag_names' => ['Default'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Usage
|
||||
$backupItem = BackupItem::factory()->withAssignments(10)->create();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Factory: RestoreRun with Group Mapping
|
||||
|
||||
```php
|
||||
// database/factories/RestoreRunFactory.php
|
||||
public function withGroupMapping(array $mapping = []): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'group_mapping' => $mapping ?: [
|
||||
'source-group-1' => 'target-group-1',
|
||||
'source-group-2' => 'target-group-2',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Usage
|
||||
$restoreRun = RestoreRun::factory()->withGroupMapping([
|
||||
'source-abc' => 'target-xyz',
|
||||
])->create();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions / Gitea CI (Example)
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/test.yml
|
||||
name: Test Feature 004
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Sail
|
||||
run: |
|
||||
docker-compose up -d
|
||||
docker-compose exec -T laravel.test composer install
|
||||
|
||||
- name: Run migrations
|
||||
run: docker-compose exec -T laravel.test php artisan migrate --force
|
||||
|
||||
- name: Run tests
|
||||
run: docker-compose exec -T laravel.test php artisan test --filter=Assignment
|
||||
|
||||
- name: Check code style
|
||||
run: docker-compose exec -T laravel.test ./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Read Planning Docs**:
|
||||
- [plan.md](plan.md) - Implementation phases
|
||||
- [research.md](research.md) - Technology decisions
|
||||
- [data-model.md](data-model.md) - Database schema
|
||||
|
||||
2. **Start with Phase 1**:
|
||||
- Run migrations
|
||||
- Add model casts and accessors
|
||||
- Write unit tests for model methods
|
||||
|
||||
3. **Build Graph Services** (Phase 2):
|
||||
- Implement `AssignmentFetcher`
|
||||
- Implement `GroupResolver`
|
||||
- Implement `ScopeTagResolver`
|
||||
- Write unit tests with mocked Graph responses
|
||||
|
||||
4. **Implement MVP** (Phase 3-4):
|
||||
- Add backup checkbox
|
||||
- Create assignment backup logic
|
||||
- Add Policy View assignments tab
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Laravel Docs**: https://laravel.com/docs/12.x
|
||||
- **Filament Docs**: https://filamentphp.com/docs/4.x
|
||||
- **Graph API Docs**: https://learn.microsoft.com/en-us/graph/api/overview
|
||||
- **Pest Docs**: https://pestphp.com/docs/
|
||||
|
||||
---
|
||||
|
||||
**Status**: Quickstart Complete
|
||||
**Next Document**: `tasks.md` (detailed task breakdown)
|
||||
566
specs/004-assignments-scope-tags/research.md
Normal file
566
specs/004-assignments-scope-tags/research.md
Normal file
@ -0,0 +1,566 @@
|
||||
# Feature 004: Assignments & Scope Tags - Research Notes
|
||||
|
||||
## Overview
|
||||
This document captures key technology decisions, API patterns, and implementation strategies for the Assignments & Scope Tags feature.
|
||||
|
||||
---
|
||||
|
||||
## Research Questions & Answers
|
||||
|
||||
### 1. How should we store assignments data?
|
||||
|
||||
**Question**: Should assignments be stored as JSONB in `backup_items` table or as separate relational tables?
|
||||
|
||||
**Options Evaluated**:
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **JSONB Column** | Simple schema, flexible structure, fast writes, matches Graph API response format | Complex queries, no foreign key constraints, larger row size |
|
||||
| **Separate Tables** (`assignment` table) | Normalized, relational integrity, easier reporting | Complex migrations, slower backups, graph-to-relational mapping overhead |
|
||||
|
||||
**Decision**: **JSONB Column** (`backup_items.assignments`)
|
||||
|
||||
**Rationale**:
|
||||
1. Assignments are **immutable snapshots** - we're not querying/filtering them frequently
|
||||
2. Graph API returns assignments as nested JSON - direct storage avoids mapping
|
||||
3. Backup/restore operations are write-heavy, not read-heavy (JSONB excels here)
|
||||
4. Simplifies restore logic (no need to reconstruct JSON from relations)
|
||||
5. Matches existing pattern for policy payloads in `backup_items.payload` (JSONB)
|
||||
|
||||
**Implementation**:
|
||||
```php
|
||||
// Migration
|
||||
Schema::table('backup_items', function (Blueprint $table) {
|
||||
$table->json('assignments')->nullable()->after('metadata');
|
||||
});
|
||||
|
||||
// Model Cast
|
||||
protected $casts = [
|
||||
'assignments' => 'array',
|
||||
// ...
|
||||
];
|
||||
|
||||
// Accessor for assignment count
|
||||
public function getAssignmentCountAttribute(): int
|
||||
{
|
||||
return count($this->assignments ?? []);
|
||||
}
|
||||
```
|
||||
|
||||
**Trade-off**: If we need advanced assignment analytics (e.g., "show me all backups targeting group X"), we'll need to:
|
||||
- Use PostgreSQL JSONB query operators (`@>`, `?`)
|
||||
- Or add a read model (separate `assignment_analytics` table populated async)
|
||||
|
||||
---
|
||||
|
||||
### 2. What's the best Graph API fallback strategy for assignments?
|
||||
|
||||
**Question**: The spec mentions a fallback strategy for fetching assignments. What's the production-tested approach?
|
||||
|
||||
**Problem Context**:
|
||||
- Graph API behavior varies by policy template family
|
||||
- Some policies return empty `/assignments` endpoint but have assignments via `$expand`
|
||||
- Known issue with certain Settings Catalog template types
|
||||
|
||||
**Strategy** (from spec FR-004.2):
|
||||
|
||||
```php
|
||||
class AssignmentFetcher
|
||||
{
|
||||
public function fetch(string $tenantId, string $policyId): array
|
||||
{
|
||||
try {
|
||||
// Primary: Direct assignments endpoint
|
||||
$response = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
|
||||
|
||||
if (!empty($response['value'])) {
|
||||
return $response['value'];
|
||||
}
|
||||
|
||||
// Fallback: Use $expand (slower but more reliable)
|
||||
$response = $this->graph->get(
|
||||
"/deviceManagement/configurationPolicies",
|
||||
[
|
||||
'$filter' => "id eq '{$policyId}'",
|
||||
'$expand' => 'assignments'
|
||||
]
|
||||
);
|
||||
|
||||
return $response['value'][0]['assignments'] ?? [];
|
||||
|
||||
} catch (GraphException $e) {
|
||||
// Log warning, return empty (fail-soft)
|
||||
Log::warning("Failed to fetch assignments for policy {$policyId}", [
|
||||
'tenant_id' => $tenantId,
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $e->getRequestId(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing Strategy**:
|
||||
- Mock both successful and failed responses
|
||||
- Test with empty `value` array (triggers fallback)
|
||||
- Test complete failure (returns empty, logs warning)
|
||||
|
||||
**Edge Cases**:
|
||||
- **Rate Limiting**: If primary call hits rate limit, fallback may also fail → log and continue
|
||||
- **Timeout**: 30-second timeout on both calls → fail-soft, mark `assignments_fetch_failed: true`
|
||||
|
||||
---
|
||||
|
||||
### 3. How to resolve group IDs to names efficiently?
|
||||
|
||||
**Question**: We need to display group names in UI, but assignments only have group IDs. What's the best resolution strategy?
|
||||
|
||||
**Graph API Options**:
|
||||
|
||||
| Method | Endpoint | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| **Batch Resolution** | POST `/directoryObjects/getByIds` | Single request for 100+ IDs, stable API | Requires POST, batch size limit (1000) |
|
||||
| **Filter Query** | GET `/groups?$filter=id in (...)` | Standard GET | Requires advanced query (not all tenants enabled), URL length limits |
|
||||
| **Individual GET** | GET `/groups/{id}` per group | Simple | N+1 queries, slow for 50+ groups |
|
||||
|
||||
**Decision**: **POST `/directoryObjects/getByIds` with Caching**
|
||||
|
||||
**Rationale**:
|
||||
1. Most reliable for large group counts (tested with 500+ groups)
|
||||
2. Single request vs N requests
|
||||
3. Works without advanced query requirements
|
||||
4. Supports multiple object types (groups, users, devices)
|
||||
|
||||
**Implementation**:
|
||||
```php
|
||||
class GroupResolver
|
||||
{
|
||||
public function resolveGroupIds(array $groupIds, string $tenantId): array
|
||||
{
|
||||
// Check cache first (5 min TTL)
|
||||
$cacheKey = "groups:{$tenantId}:" . md5(implode(',', $groupIds));
|
||||
|
||||
if ($cached = Cache::get($cacheKey)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Batch resolve
|
||||
$response = $this->graph->post('/directoryObjects/getByIds', [
|
||||
'ids' => $groupIds,
|
||||
'types' => ['group'],
|
||||
]);
|
||||
|
||||
$resolved = collect($response['value'])->keyBy('id')->toArray();
|
||||
|
||||
// Handle orphaned IDs
|
||||
$result = [];
|
||||
foreach ($groupIds as $id) {
|
||||
$result[$id] = $resolved[$id] ?? [
|
||||
'id' => $id,
|
||||
'displayName' => null, // Will render as "Unknown Group (ID: {id})"
|
||||
'orphaned' => true,
|
||||
];
|
||||
}
|
||||
|
||||
Cache::put($cacheKey, $result, now()->addMinutes(5));
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Optimization**: Pre-warm cache when entering Group Mapping wizard (fetch all target tenant groups once).
|
||||
|
||||
---
|
||||
|
||||
### 4. What's the UX pattern for Group Mapping with 500+ groups?
|
||||
|
||||
**Question**: How do we make group mapping usable for large tenants?
|
||||
|
||||
**UX Requirements** (from NFR-004.2):
|
||||
- Must support 500+ groups
|
||||
- Must be searchable
|
||||
- Should feel responsive
|
||||
|
||||
**Filament Solution**: **Searchable Select with Server-Side Filtering**
|
||||
|
||||
```php
|
||||
use Filament\Forms\Components\Select;
|
||||
|
||||
Select::make('target_group_id')
|
||||
->label('Target Group')
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search) =>
|
||||
Group::query()
|
||||
->where('tenant_id', $this->targetTenantId)
|
||||
->where('display_name', 'ilike', "%{$search}%")
|
||||
->limit(50)
|
||||
->pluck('display_name', 'graph_id')
|
||||
)
|
||||
->getOptionLabelUsing(fn ($value): ?string =>
|
||||
Group::where('graph_id', $value)->first()?->display_name
|
||||
)
|
||||
->lazy() // Load options only when dropdown opens
|
||||
->debounce(500) // Wait 500ms after typing before searching
|
||||
```
|
||||
|
||||
**Caching Strategy**:
|
||||
```php
|
||||
// Pre-warm cache when wizard opens
|
||||
public function mount()
|
||||
{
|
||||
// Cache all target tenant groups for 5 minutes
|
||||
Cache::remember("groups:{$this->targetTenantId}", 300, function () {
|
||||
return $this->graph->get('/groups?$select=id,displayName')['value'];
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative** (if Filament Select is slow): Use Livewire component with Alpine.js dropdown + AJAX search.
|
||||
|
||||
---
|
||||
|
||||
### 5. How to handle assignment restore failures gracefully?
|
||||
|
||||
**Question**: What if some assignments succeed and others fail during restore?
|
||||
|
||||
**Requirements** (from FR-004.12, FR-004.13):
|
||||
- Continue with remaining assignments (fail-soft)
|
||||
- Log per-assignment outcome
|
||||
- Report final status: "3 of 5 assignments restored"
|
||||
|
||||
**DELETE-then-CREATE Pattern**:
|
||||
```php
|
||||
class AssignmentRestoreService
|
||||
{
|
||||
public function restore(string $policyId, array $assignments, array $groupMapping): array
|
||||
{
|
||||
$outcomes = [];
|
||||
|
||||
// Step 1: DELETE existing assignments (clean slate)
|
||||
$existing = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments");
|
||||
|
||||
foreach ($existing['value'] as $assignment) {
|
||||
try {
|
||||
$this->graph->delete("/deviceManagement/configurationPolicies/{$policyId}/assignments/{$assignment['id']}");
|
||||
// 204 No Content = success
|
||||
} catch (GraphException $e) {
|
||||
Log::warning("Failed to delete assignment {$assignment['id']}", [
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $e->getRequestId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: CREATE new assignments (with mapped IDs)
|
||||
foreach ($assignments as $assignment) {
|
||||
// Apply group mapping
|
||||
if (isset($assignment['target']['groupId'])) {
|
||||
$sourceGroupId = $assignment['target']['groupId'];
|
||||
|
||||
// Skip if marked in group mapping
|
||||
if (isset($groupMapping[$sourceGroupId]) && $groupMapping[$sourceGroupId] === 'SKIP') {
|
||||
$outcomes[] = ['status' => 'skipped', 'assignment' => $assignment];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace with target group ID
|
||||
$assignment['target']['groupId'] = $groupMapping[$sourceGroupId] ?? $sourceGroupId;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->graph->post(
|
||||
"/deviceManagement/configurationPolicies/{$policyId}/assignments",
|
||||
$assignment
|
||||
);
|
||||
// 201 Created = success
|
||||
|
||||
$outcomes[] = ['status' => 'success', 'assignment' => $assignment];
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'action' => 'restore.assignment.created',
|
||||
'resource_type' => 'assignment',
|
||||
'resource_id' => $response['id'],
|
||||
'metadata' => $assignment,
|
||||
]);
|
||||
|
||||
} catch (GraphException $e) {
|
||||
$outcomes[] = [
|
||||
'status' => 'failed',
|
||||
'assignment' => $assignment,
|
||||
'error' => $e->getMessage(),
|
||||
'request_id' => $e->getRequestId(),
|
||||
];
|
||||
|
||||
Log::error("Failed to restore assignment", [
|
||||
'policy_id' => $policyId,
|
||||
'assignment' => $assignment,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Rate limit protection: 100ms delay between POSTs
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
return $outcomes;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Outcome Reporting**:
|
||||
```php
|
||||
$successCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'success'));
|
||||
$failedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'failed'));
|
||||
$skippedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'skipped'));
|
||||
|
||||
Notification::make()
|
||||
->title("Assignment Restore Complete")
|
||||
->body("{$successCount} of {$total} assignments restored. {$failedCount} failed, {$skippedCount} skipped.")
|
||||
->success()
|
||||
->send();
|
||||
```
|
||||
|
||||
**Why DELETE-then-CREATE vs PATCH**?
|
||||
- PATCH requires knowing existing assignment IDs (not available from backup)
|
||||
- DELETE-then-CREATE is idempotent (can rerun safely)
|
||||
- Graph API doesn't support "upsert" for assignments
|
||||
|
||||
---
|
||||
|
||||
### 6. How to handle Scope Tags?
|
||||
|
||||
**Question**: Scope Tags are simpler than assignments (just an array of IDs in policy payload). How should we handle them?
|
||||
|
||||
**Requirements**:
|
||||
- Extract `roleScopeTagIds` array from policy payload during backup
|
||||
- Resolve Scope Tag IDs to names for display
|
||||
- Preserve IDs during restore (if they exist in target tenant)
|
||||
- Warn if Scope Tag doesn't exist in target (don't block restore)
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```php
|
||||
// During Backup
|
||||
class AssignmentBackupService
|
||||
{
|
||||
public function backupScopeTags(array $policyPayload): array
|
||||
{
|
||||
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0']; // Default Scope Tag
|
||||
|
||||
// Resolve names (cached for 1 hour)
|
||||
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds);
|
||||
|
||||
return [
|
||||
'scope_tag_ids' => $scopeTagIds,
|
||||
'scope_tag_names' => array_column($scopeTags, 'displayName'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// During Restore
|
||||
class AssignmentRestoreService
|
||||
{
|
||||
public function validateScopeTags(array $scopeTagIds, string $targetTenantId): array
|
||||
{
|
||||
$targetScopeTags = $this->scopeTagResolver->getAllForTenant($targetTenantId);
|
||||
$targetScopeTagIds = array_column($targetScopeTags, 'id');
|
||||
|
||||
$matched = array_intersect($scopeTagIds, $targetScopeTagIds);
|
||||
$missing = array_diff($scopeTagIds, $targetScopeTagIds);
|
||||
|
||||
if (!empty($missing)) {
|
||||
Log::warning("Some Scope Tags not found in target tenant", [
|
||||
'missing' => $missing,
|
||||
'matched' => $matched,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'matched' => $matched,
|
||||
'missing' => $missing,
|
||||
'can_proceed' => true, // Always allow restore
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Caching**:
|
||||
```php
|
||||
class ScopeTagResolver
|
||||
{
|
||||
public function resolve(array $scopeTagIds): array
|
||||
{
|
||||
return Cache::remember("scope_tags:all", 3600, function () {
|
||||
return $this->graph->get('/deviceManagement/roleScopeTags?$select=id,displayName')['value'];
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Display in UI**:
|
||||
```php
|
||||
// Restore Preview
|
||||
Scope Tags:
|
||||
✅ 2 matched (Default, HR-Admins)
|
||||
⚠️ 1 not found in target (Finance-Admins)
|
||||
|
||||
Note: Restore will proceed. Missing Scope Tags will be created automatically by Graph API or ignored.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. What's the testing strategy?
|
||||
|
||||
**Question**: How do we ensure reliability across all scenarios?
|
||||
|
||||
**Test Pyramid**:
|
||||
|
||||
```
|
||||
/\
|
||||
/ \
|
||||
/ UI \ Browser Tests (5)
|
||||
/------\ - Group Mapping wizard flow
|
||||
/ Feature \ Feature Tests (10)
|
||||
/----------\ - Backup with assignments
|
||||
/ Unit \ - Policy view rendering
|
||||
/--------------\ - Restore with mapping
|
||||
- Assignment fetch failures
|
||||
|
||||
Unit Tests (15)
|
||||
- AssignmentFetcher
|
||||
- GroupResolver
|
||||
- ScopeTagResolver
|
||||
- Model accessors
|
||||
- Service methods
|
||||
```
|
||||
|
||||
**Key Test Scenarios**:
|
||||
|
||||
1. **Unit Tests** (Fast, isolated):
|
||||
```php
|
||||
it('fetches assignments with fallback on empty response', function () {
|
||||
$fetcher = new AssignmentFetcher($this->mockGraph);
|
||||
|
||||
// Mock primary call returning empty
|
||||
$this->mockGraph
|
||||
->shouldReceive('get')
|
||||
->with('/deviceManagement/configurationPolicies/abc-123/assignments')
|
||||
->andReturn(['value' => []]);
|
||||
|
||||
// Mock fallback call returning assignments
|
||||
$this->mockGraph
|
||||
->shouldReceive('get')
|
||||
->with('/deviceManagement/configurationPolicies', [...])
|
||||
->andReturn(['value' => [['assignments' => [...]]]]);
|
||||
|
||||
$result = $fetcher->fetch('tenant-1', 'abc-123');
|
||||
|
||||
expect($result)->toHaveCount(3);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Feature Tests** (Integration):
|
||||
```php
|
||||
it('backs up policy with assignments when checkbox enabled', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$policy = Policy::factory()->for($tenant)->create();
|
||||
|
||||
// Mock Graph API
|
||||
Http::fake([
|
||||
'*/assignments' => Http::response(['value' => [...]]),
|
||||
'*/roleScopeTags' => Http::response(['value' => [...]]),
|
||||
]);
|
||||
|
||||
$backupItem = (new AssignmentBackupService)->backup(
|
||||
tenantId: $tenant->id,
|
||||
policyId: $policy->graph_id,
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
expect($backupItem->assignments)->toHaveCount(3);
|
||||
expect($backupItem->metadata['assignment_count'])->toBe(3);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Browser Tests** (E2E):
|
||||
```php
|
||||
it('allows group mapping during restore wizard', function () {
|
||||
$page = visit('/restore/wizard');
|
||||
|
||||
$page->assertSee('Group Mapping Required')
|
||||
->fill('source-group-abc-123', 'target-group-xyz-789')
|
||||
->click('Continue')
|
||||
->assertSee('Restore Preview')
|
||||
->click('Restore')
|
||||
->assertSee('Restore Complete');
|
||||
|
||||
$restoreRun = RestoreRun::latest()->first();
|
||||
expect($restoreRun->group_mapping)->toHaveKey('source-group-abc-123');
|
||||
});
|
||||
```
|
||||
|
||||
**Manual QA Checklist**:
|
||||
- [ ] Create backup with assignments (same tenant)
|
||||
- [ ] Create backup without assignments
|
||||
- [ ] View policy with assignments tab
|
||||
- [ ] Restore to same tenant (auto-match groups)
|
||||
- [ ] Restore to different tenant (group mapping wizard)
|
||||
- [ ] Handle orphaned group IDs gracefully
|
||||
- [ ] Skip assignments during group mapping
|
||||
- [ ] Handle Graph API failures (assignments fetch, group resolution)
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack Summary
|
||||
|
||||
| Component | Technology | Justification |
|
||||
|-----------|-----------|---------------|
|
||||
| **Assignment Storage** | PostgreSQL JSONB | Immutable snapshots, matches Graph API format |
|
||||
| **Graph API Fallback** | Primary + Fallback pattern | Production-tested reliability |
|
||||
| **Group Resolution** | POST `/directoryObjects/getByIds` + Cache | Batch efficiency, 5min TTL |
|
||||
| **Group Mapping UX** | Filament Searchable Select + Debounce | Supports 500+ groups, responsive |
|
||||
| **Restore Pattern** | DELETE-then-CREATE | Idempotent, no ID tracking needed |
|
||||
| **Scope Tag Handling** | Extract from payload + Cache | Simple, 1hr TTL |
|
||||
| **Error Handling** | Fail-soft + Per-item logging | Graceful degradation |
|
||||
| **Caching** | Laravel Cache (Redis/File) | 5min groups, 1hr scope tags |
|
||||
|
||||
---
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
**Target Metrics**:
|
||||
- Assignment fetch: < 2 seconds per policy (with fallback)
|
||||
- Group resolution: < 1 second for 100 groups (batched)
|
||||
- Group mapping UI: < 500ms search response with 500+ groups (debounced)
|
||||
- Assignment restore: < 30 seconds for 20 policies (sequential with 100ms delay)
|
||||
|
||||
**Optimization Opportunities**:
|
||||
1. Pre-warm group cache when wizard opens
|
||||
2. Batch assignment POSTs (if Graph supports batch endpoint - check docs)
|
||||
3. Use queues for large restores (20+ policies)
|
||||
4. Add progress polling for long-running restores
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (For Implementation)
|
||||
|
||||
1. **Smart Matching**: Should we auto-suggest group mappings based on name similarity?
|
||||
- **Defer**: Manual mapping MVP, consider for Phase 2
|
||||
|
||||
2. **Dynamic Groups**: How to handle membership rules?
|
||||
- **Defer**: Skip initially, document as limitation
|
||||
|
||||
3. **Assignment Filters**: Do we preserve device/user filters?
|
||||
- **Yes**: Store entire assignment object in JSONB
|
||||
|
||||
4. **Batch API**: Does Graph support batch assignment POST?
|
||||
- **Research**: Check Graph API docs for `/batch` endpoint support
|
||||
|
||||
---
|
||||
|
||||
**Status**: Research Complete
|
||||
**Next Document**: `data-model.md`
|
||||
Loading…
Reference in New Issue
Block a user