feat/004-assignments-scope-tags #4
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