# Feature 004: Assignments & Scope Tags for Settings Catalog Policies

## Overview
Extend backup and restore functionality to include **Assignments** (group/user/device targeting) and **Scope Tags** for Settings Catalog policies. This ensures complete policy state capture and enables cross-tenant migrations with group mapping.

## Problem Statement
Currently, TenantPilot backs up only the Settings Catalog policy configuration itself. However, **Assignments** (which groups/users/devices the policy applies to) and **Scope Tags** (RBAC-based policy visibility) are critical metadata that make a policy actionable.

**Without this feature:**
- Restored policies have no assignments → must be manually configured
- Cross-tenant migrations lose targeting context
- Scope Tags are not preserved → RBAC-scoped admins may lose access
- Backup previews don't show assignment counts or scope

**With this feature:**
- Complete policy state backup (config + assignments + scope tags)
- Cross-tenant restore with intelligent group mapping
- Clear preview/diff of assignment changes
- Optional inclusion (lightweight backups possible)

## Goals
- **Primary**: Backup and restore assignments for Settings Catalog policies
- **Secondary**: Include Scope Tags in backup metadata
- **Tertiary**: Group mapping wizard for cross-tenant restores
- **Non-Goal**: Assignments for non-Settings Catalog types (future features 006-009)

## Scope
- **Policy Types**: `settingsCatalogPolicy` only (initially)
- **Graph Endpoints**:
  - GET `/deviceManagement/configurationPolicies/{id}/assignments`
  - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments)
  - DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback)
  - GET `/deviceManagement/roleScopeTags` (for reference data)
  - GET `/deviceManagement/assignmentFilters` (for filter names)
- **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true)
- **Restore Behavior**: With group mapping UI for unresolved group IDs

---

## User Stories

### User Story 1 - Capture Assignments & Scope Tags (Priority: P1)

**As an admin**, I want to optionally include assignments and scope tags when capturing policy snapshots or adding policies to a Backup Set, so that I have complete policy state for migration or disaster recovery.

**Acceptance Criteria:**
1. **Given** I add policies to a Backup Set (or capture a snapshot from the Policy view),
   **When** I enable "Include assignments" and/or "Include scope tags",
   **Then** the capture stores:
   - Assignments on the PolicyVersion (include/exclude targets + filters)
   - Scope tags on the PolicyVersion as `{ids, names}`
   - A BackupItem linked via `policy_version_id` that copies assignments for restore

2. **Given** I create a Backup Set,
   **When** I complete the form,
   **Then** no assignments/scope tags checkbox appears on that screen (selection happens when adding policies).

3. **Given** I disable either checkbox,
   **When** the capture completes,
   **Then** the corresponding PolicyVersion fields are `null` and the BackupItem is created without those data.

---

### User Story 2 - Policy Version View with Assignments (Priority: P1)

**As an admin**, I want to see a policy version's captured assignments and scope tags, so I understand targeting and visibility at that snapshot.

**Acceptance Criteria:**
1. **Given** I view a Settings Catalog Policy Version,
   **When** assignments were captured,
   **Then** I see:
   - Include/Exclude group targets with group display name (or "Unknown Group (ID: ...)")
   - Filter name (if present) with filter mode (include/exclude)
   - Scope tags list from the version

2. **Given** assignments were not captured for this version,
   **When** I open the assignments panel,
   **Then** I see "Assignments were not captured for this version."

3. **Given** scope tags were not captured,
   **When** I view the version,
   **Then** I see a "Scope tags not captured" empty state.

---

### User Story 3 - Restore with Group Mapping (Priority: P1)

**As an admin**, I want to map source tenant groups to target tenant groups during restore, so I can migrate policies across tenants without manual re-assignment.

**Acceptance Criteria:**
1. **Given** I restore a Backup Item with assignments to a different tenant,
   **When** the restore preview detects unresolved group IDs,
   **Then** the wizard shows a "Group Mapping" step with:
   - Source group name and ID
   - Target tenant groups dropdown (searchable)
   - "Skip assignment" checkbox per group

2. **Given** I map 3 source groups to target groups,
   **When** I confirm the restore,
   **Then** the restored policy has assignments pointing to the mapped target groups

3. **Given** I choose "Skip assignment" for 2 groups,
   **When** the restore completes,
   **Then** those 2 assignments are NOT created (only mapped groups restored)

4. **Given** all source groups exist in target tenant (same IDs),
   **When** the restore runs,
   **Then** the Group Mapping step is skipped (auto-matched)

---

### User Story 4 - Restore Preview with Assignment Diff (Priority: P2)

**As an admin**, I want to see assignment changes in the restore preview, so I know what will be modified before executing.

**Acceptance Criteria:**
1. **Given** I preview a restore that includes assignments,
   **When** the target policy has different assignments,
   **Then** the preview shows:
   - "Assignments: 3 will be added, 1 removed, 2 unchanged"
   - Expandable diff: Added (green), Removed (red), Unchanged (gray)

2. **Given** the target policy has no existing assignments,
   **When** I preview the restore,
   **Then** the preview shows "Assignments: 5 will be created"

3. **Given** I restore a backup without assignments,
   **When** I preview the restore,
   **Then** the assignment section shows "Not included in backup"

---

## Functional Requirements

### Backup & Storage

**FR-004.1**: System MUST provide separate checkboxes "Include assignments" and "Include scope tags" on:
- Add Policies to Backup Set action
- Capture snapshot action in Policy view
Defaults: checked. Backup Set creation form MUST NOT show these checkboxes.

**FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy:
1. Try: `/deviceManagement/configurationPolicies/{id}/assignments`
2. If empty/fails: Try `$expand=assignments` on policy fetch
3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true` in PolicyVersion metadata.
   - This flag covers any failure during assignment capture/enrichment (fetch, group resolution, filter resolution).

**FR-004.3**: System MUST enrich assignments with:
- Group display name + orphaned flag via `/directoryObjects/getByIds`
- Assignment filter name via `/deviceManagement/assignmentFilters`
- Preserve target type (include/exclude) and filter mode (`deviceAndAppManagementAssignmentFilterType`)
- If filter name lookup fails or filter ID is unknown, keep filter ID + mode, omit the name, and continue capture (UI displays filter ID when name is missing).

**FR-004.4**: System MUST store assignments and scope tags on PolicyVersion:
- `policy_versions.assignments` (array, nullable)
- `policy_versions.scope_tags` as `{ids: [], names: []}` (nullable)
- hashes for deduplication (`assignments_hash`, `scope_tags_hash`)
BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore.

**FR-004.5**: PolicyVersion metadata MUST include capture flags (see Data Model). BackupItem metadata MAY mirror these flags for display/audit, but PolicyVersion is the source of truth. Assignment counts are derived from `assignments` at display time.

### UI Display

**FR-004.6**: Policy Version view MUST show an assignments panel for Settings Catalog versions displaying:
- Include/Exclude targets with group display name or "Unknown Group (ID: ...)"
- Assignment filter name + filter mode (include/exclude) when present
- Scope Tags section
- Empty state if assignments or scope tags were not captured

**FR-004.7**: Backup Set items table MUST show assignment count (derived from `backup_items.assignments`) and scope tag names from the linked PolicyVersion.

**FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon.

**Terminology**:
- **Orphaned group ID**: A group ID referenced in assignments that cannot be resolved in the source tenant during capture.
- **Unresolved group ID**: A group ID not found in the target tenant during restore mapping.
- UI SHOULD render both as "Unknown Group (ID: ...)" with warning styling.

### Restore with Group Mapping

**FR-004.9**: Restore preview MUST detect unresolved (target-missing) group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved.

**FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing:
- Source group (name from backup metadata or ID if name unavailable)
- Target group dropdown (searchable, populated from target tenant)
- "Skip" checkbox

**FR-004.11**: System MUST persist group mapping selections in RestoreRun metadata for audit and rerun purposes.

**FR-004.12**: When restoring assignments, system MUST:
1. Replace source group IDs with mapped target group IDs in assignment objects
2. Skip assignments marked "Skip" in group mapping
3. Preserve include/exclude intent and filters
4. Execute restore via assign action when supported:
   - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
   - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
     - GET existing assignments from target policy
     - DELETE each existing assignment (via DELETE `/assignments/{id}`)
     - POST each new/mapped assignment (via POST `/assignments`)
5. Handle failures gracefully:
   - 204 No Content on DELETE = success
   - 201 Created on POST = success
   - Log request-id/client-request-id on any failure
6. Continue with remaining assignments if one fails (fail-soft)
7. Restore is best-effort: no transactional rollback between DELETE and POST. If DELETE succeeds but POST fails, record a failed outcome, mark the restore as partial, and allow retry.

**FR-004.13**: System MUST handle assignment restore failures gracefully:
- Log per-assignment outcome (success/skip/failure)
- Continue with remaining assignments
- Report final status: "3 of 5 assignments restored"

**FR-004.14**: System MUST write audit log entries:
- `backup.assignments.included` (when checkbox enabled)
- `restore.group_mapping.applied` (with mapping details)
- `restore.assignment.created` (per assignment)
- `restore.assignment.skipped` (per skipped)

### Scope Tags

**FR-004.15**: System MUST extract Scope Tag IDs from policy payload's `roleScopeTagIds` array during backup.

**FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour).

**FR-004.17**: During restore, system MUST preserve Scope Tag IDs that exist in the target tenant. If a Scope Tag ID is missing:
- Log a warning
- Proceed without that tag (best-effort, Graph API default behavior)

**FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant".

---

## Non-Functional Requirements

**NFR-004.1**: Assignment fetching MUST NOT block capture actions (Add Policies / Capture Snapshot). Use async or fail-soft behavior.

**NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups.

**NFR-004.3**: Assignment restore MUST batch API calls (if Graph supports batch, else sequential with 100ms delay).

**NFR-004.4**: System MUST cache target tenant group list for 5 minutes during restore wizard session.

---

## Data Model Changes

### Migration: `policy_versions` assignments + scope tags

```php
Schema::table('policy_versions', function (Blueprint $table) {
    $table->json('assignments')->nullable()->after('metadata');
    $table->json('scope_tags')->nullable()->after('assignments');
    $table->string('assignments_hash', 64)->nullable()->after('scope_tags');
    $table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
    $table->index('assignments_hash');
    $table->index('scope_tags_hash');
});
```

### Migration: `backup_items` policy_version_id

```php
Schema::table('backup_items', function (Blueprint $table) {
    $table->foreignId('policy_version_id')->nullable()->constrained('policy_versions');
});
```

### Migration: `backup_items` assignments copy

```php
Schema::table('backup_items', function (Blueprint $table) {
    $table->json('assignments')->nullable()->after('metadata');
    // copy of PolicyVersion assignments for restore safety
});
```

### Migration: `restore_runs` table extension

```php
Schema::table('restore_runs', function (Blueprint $table) {
    $table->json('group_mapping')->nullable()->after('results');
    // stores: {"source-group-id": "target-group-id", ...}
});
```

### `policy_versions.scope_tags` JSONB schema

```json
{
  "ids": ["0", "123"],
  "names": ["Default", "HR"]
}
```

### `policy_versions.metadata` JSONB schema

```json
{
  "has_assignments": true,
  "has_scope_tags": true,
  "has_orphaned_assignments": false,
  "assignments_fetch_failed": false
}
```

BackupItem metadata MAY include the same flags copied from the PolicyVersion for display/audit, but PolicyVersion is the source of truth.

---

## Graph API Integration

### Endpoints to Add (Production-Tested Strategies)

1. **GET Assignments (with Fallback Strategy)**
   - **Primary**: `/deviceManagement/configurationPolicies/{id}/assignments`
     - Returns: `{ value: [assignment objects] }`
     - Contract: `type_family: [#microsoft.graph.deviceManagementConfigurationPolicyAssignment]`
   - **Fallback** (if primary fails/returns empty): 
     - `/deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'`
     - Client-side filter to extract assignments
   - **Reason**: Known Graph API quirks with assignment expansion on certain template families

2. **Assignment Apply** (Assign action + fallback)
   
   - **POST** `/deviceManagement/configurationPolicies/{id}/assign`
     - Body: `{ "assignments": [ ... ] }`
     - Returns: 200/204 on success (no per-assignment IDs)
     - Example:
       ```json
       {
         "assignments": [
           {
             "target": {
               "@odata.type": "#microsoft.graph.groupAssignmentTarget",
               "groupId": "abc-123-def"
             },
             "intent": "apply"
           }
         ]
       }
       ```

   - **Fallback** (when `/assign` is unsupported):
     - **GET** `/deviceManagement/configurationPolicies/{id}/assignments`
     - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
     - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object)
   
   - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).

3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
   - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
   - Returns: `{ value: [{ id, displayName, ... }] }`
   - **Reason**: More stable than `$filter=id in (...)` which can fail with advanced query requirements
   - Example:
     ```json
     {
       "ids": ["abc-123", "def-456"],
       "types": ["group"]
     }
     ```

4. **GET** `/deviceManagement/roleScopeTags?$select=id,displayName`
   - For Scope Tag resolution (cache 1 hour)
   - Scope Tag IDs also available in policy payload's `roleScopeTagIds` array

5. **GET** `/deviceManagement/assignmentFilters?$select=id,displayName`
   - For assignment filter name resolution (cache 1 hour)
   - Filter IDs in assignments: `deviceAndAppManagementAssignmentFilterId`
   - Filter mode: `deviceAndAppManagementAssignmentFilterType` (include/exclude)

### Graph Contract Updates

Add to `config/graph_contracts.php`:

```php
'settingsCatalogPolicy' => [
    // ... existing config
    
    // Assignments CRUD (standard Graph pattern)
    'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
    'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
    'assignments_create_method' => 'POST',
    'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
    'assignments_update_method' => 'PATCH',
    'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
    'assignments_delete_method' => 'DELETE',
    
    // Scope Tags
    'supports_scope_tags' => true,
    'scope_tag_field' => 'roleScopeTagIds', // array in policy payload
],
```

---

## UI Mockups (Wireframe Descriptions)

### Policy Version View - Assignments Panel

```
[General] [Settings] [JSON]

Assignments (5)
┌─────────────────────────────────────────────────┐
│ Type          │ Name              │ Filter     │ ID      │
├─────────┼───────────────────┼─────────┼─────────┤
│ Include group │ All Users         │ Test (include) │ abc-123 │
│ Exclude group │ Contractors       │ -              │ def-456 │
└─────────────────────────────────────────────────┘

Scope Tags (2)
  • Default (ID: 0)
  • HR-Admins (ID: 123)
```

### Add Policies to Backup Set - Checkboxes

```
Add Policies to Backup Set
─────────────────────────
Select Policies: [Settings Catalog: 15 selected]

☑ Include assignments
  Captures include/exclude targeting and filters.

☑ Include scope tags
  Captures policy scope tag IDs.

[Cancel]  [Add Policies]
```

### Restore Wizard - Group Mapping Step

```
Restore Preview > Group Mapping > Confirm

Group Mapping Required
Some groups from source tenant don't exist in target tenant.

┌────────────────────────────────────────────────────────┐
│ Source Group          │ Target Group          │ Action │
├───────────────────────┼───────────────────────┼────────┤
│ All Users (abc-123)   │ [Select target group] │ ☐ Skip │
│ HR Team (def-456)     │ HR Department         │        │
│ Contractors (ghi-789) │ [Select target group] │ ☑ Skip │
└────────────────────────────────────────────────────────┘

[Back]  [Continue]
```

---

## Testing Strategy

### Unit Tests
- `AssignmentFetcherTest`: Mock Graph responses, test parsing
- `GroupMapperTest`: Test ID resolution, mapping logic
- `ScopeTagResolverTest`: Test caching, name resolution
- `AssignmentFilterResolverTest`: Test caching and ID filtering

### Feature Tests
- `BackupWithAssignmentsConsistencyTest`: PolicyVersion as source of truth
- `VersionCaptureWithAssignmentsTest`: Snapshot capture with assignments/scope tags
- `PolicyVersionViewAssignmentsTest`: UI rendering, orphaned IDs, filters
- `RestoreGroupMappingTest`: Wizard flow, mapping persistence
- `RestoreAssignmentApplicationTest`: Graph API calls, outcomes

### Manual QA
- Add policies to backup set with/without assignments and scope tags
- Capture snapshot with/without assignments and scope tags
- Restore to same tenant (auto-match groups)
- Restore to different tenant (group mapping wizard)
- Handle orphaned group IDs gracefully

---

## Rollout Plan

### Phase 1: Backup with Assignments (MVP)
- Add checkboxes on Add Policies + Capture Snapshot actions
- Fetch assignments from Graph
- Store on PolicyVersion (copy assignments to BackupItem)
- Display in Policy Version view (read-only)
- **Duration**: ~8-12 hours

### Phase 2: Restore with Group Mapping
- Add Group Mapping wizard step
- Implement ID resolution
- Apply assignments on restore
- **Duration**: ~12-16 hours

### Phase 3: Scope Tags
- Resolve Scope Tag names
- Display in Policy Version view
- Handle restore warnings
- **Duration**: ~4-6 hours

### Phase 4: Future Extensions
- Feature 006: Extend to `compliancePolicy`
- Feature 007: Extend to `deviceConfiguration`
- Feature 008: Extend to Conditional Access
- Feature 009: Assignment analytics/reporting

---

## Dependencies
- Feature 001: Backup/Restore core (✅ complete)
- Graph Contract Registry (✅ complete)
- Filament multi-step forms (built-in)

## Risks & Mitigations

| Risk | Mitigation |
|------|------------|
| Graph API assignments endpoint slow/fails | Fail-soft with warning |
| Target tenant has 1000+ groups | Searchable dropdown with pagination |
| Group IDs change across tenants | Group name-based matching fallback |
| Scope Tag IDs don't exist in target | Log warning, allow policy creation |

---

## Success Criteria

1. ✅ Capture checkboxes functional, assignments captured
2. ✅ Policy Version view shows assignments widget with accurate data
3. ✅ Group Mapping wizard handles 100+ groups smoothly
4. ✅ Restore applies assignments with 90%+ success rate
5. ✅ Audit logs record all mapping decisions
6. ✅ Tests achieve 85%+ coverage for new code

---

## Open Questions
1. Should we support "smart matching" (group name similarity) for group mapping?
2. How to handle dynamic groups (membership rules) - copy rules or skip?
3. Should Scope Tag warnings block restore or just warn?

---

**Status**: Draft for Review  
**Created**: 2025-12-22  
**Author**: AI + Ahmed  
**Next Steps**: Review → Plan → Tasks
