--- description: "Task list for TenantPilot v1 implementation" --- # Tasks: TenantPilot v1 **Input**: Design documents from `.specify/spec.md` and `.specify/plan.md` **Prerequisites**: plan.md (complete), spec.md (complete) **Status snapshot** - Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, permissions/health, housekeeping/UX, ops) - Next up: Phase 14 (US8) delegated Intune RBAC onboarding wizard (synchronous) ## Phase 1: Setup (Shared Infrastructure) - [x] T001 [P] [Shared] Confirm Sail/Env ready; ensure `.env` has PostgreSQL settings for Sail and Filament admin user seeded (if missing) in `database/seeders/`. - [x] T002 [P] [Shared] Add baseline docs for local dev and staging promotion notes in `README.md` (Sail commands, staging-before-prod reminder). ## Phase 2: Foundational (Blocking Prerequisites) - [x] T003 [Shared] Add tenant-aware migrations for `tenants`, `policies`, `policy_versions`, `backup_sets`, `backup_items`, `restore_runs`, `audit_logs` with JSONB payloads and FK/time indexes in `database/migrations/`. - [x] T004 [Shared] Create models with relationships and guarded attributes for the above entities in `app/Models/`. - [x] T005 [Shared] Implement Graph abstraction contracts (`GraphClientInterface`, error mapping, logging hooks) in `app/Services/Graph/` with a mockable adapter. - [x] T006 [Shared] Add audit logging service/helper to capture actor, tenant, operation, resources, outcome in `app/Services/Intune/AuditLogger.php`. - [x] T007 [Shared] Seed supported policy types/metadata for initial scope in `database/seeders/PoliciesSeeder.php` and ensure tenant scoping. ## Phase 3: User Story 1 - Policy inventory listing (Priority: P1) ### Tests for User Story 1 - [x] T008 [P] [US1] Feature test for Filament policy listing and filtering (tenant-scoped) in `tests/Feature/Filament/PolicyListingTest.php` using mocked Graph sync. ### Implementation for User Story 1 - [x] T009 [US1] Implement policy sync/import orchestrator using Graph abstraction in `app/Services/Intune/PolicySyncService.php` (no direct Graph in UI). - [x] T010 [US1] Create Filament resource/table for policies with filters and metadata columns in `app/Filament/Resources/PolicyResource.php`. - [x] T011 [US1] Add command/job to sync policies (queues-ready) in `app/Console/Commands/SyncPolicies.php` and queue job under `app/Jobs/`. ## Phase 4: User Story 2 - Backup creation and browsing (Priority: P1) ### Tests for User Story 2 - [x] T012 [P] [US2] Feature test for creating backup sets with multiple policies and verifying immutable JSONB snapshots + audit log in `tests/Feature/Filament/BackupCreationTest.php`. ### Implementation for User Story 2 - [x] T013 [US2] Implement backup domain service to assemble snapshots from policies with Graph payload retrieval in `app/Services/Intune/BackupService.php`. - [x] T014 [US2] Add Filament resource/pages for backup sets and items (list/detail) in `app/Filament/Resources/BackupSetResource.php`. - [x] T131 [UX] [US2] Refactor BackupSet policy selection to RelationManager: - Remove the multi-select policy picker from the BackupSet **Create** form (keep Create minimal: name/description). - After create, redirect to BackupSet **Edit/View** where items can be managed. - Add `BackupItemsRelationManager` to `BackupSetResource` showing a table with columns: Policy Name, Type (badge), Restore (badge), Risk (badge). - Add header action “Policies hinzufügen” (searchable, multiple) that adds items/attaches policies **tenant-scoped** and prevents duplicates per BackupSet. - Provide a remove action (detach/soft-delete as per domain rules). - [x] T132 [P] [US2] Update/extend `tests/Feature/Filament/BackupCreationTest.php` to cover the new UX flow: - Create BackupSet without policies. - Add multiple policies via RelationManager action. - Verify immutable JSONB snapshots + audit log behavior remains correct. - [x] T015 [US2] Wire audit logging for backup creation events in `app/Services/Intune/BackupService.php` using `AuditLogger`. ## Phase 5: User Story 3 - Version history and diff (Priority: P1) ### Tests for User Story 3 - [x] T016 [P] [US3] Feature test for version capture and timeline display in `tests/Feature/Filament/PolicyVersionTest.php`. - [x] T017 [P] [US3] Unit test for diff generation (human summary + JSON diff) in `tests/Unit/VersionDiffTest.php`. ### Implementation for User Story 3 - [x] T018 [US3] Implement version capture service with immutable JSONB writes in `app/Services/Intune/VersionService.php`. - [x] T019 [US3] Create diff helper (summary + structured JSON) in `app/Services/Intune/VersionDiff.php` and surface in Filament version compare view in `app/Filament/Resources/PolicyVersionResource.php`. - [x] T020 [US3] Hook version capture into relevant flows (manual trigger + backup/restore hooks) ensuring audit logging. ## Phase 6: User Story 4 - Restore with preview and confirmation (Priority: P1) ### Tests for User Story 4 - [x] T021 [P] [US4] Feature test for restore preview (change summary, conflicts, selective items) in `tests/Feature/Filament/RestorePreviewTest.php`. - [x] T022 [P] [US4] Feature test for confirmed restore execution capturing audit logs and per-item outcomes in `tests/Feature/Filament/RestoreExecutionTest.php`. ### Implementation for User Story 4 - [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. - [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. - [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. ## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) ### Implementation for User Story 5 - [x] T026 [US5] Document Dokploy staging→production promotion steps, required env vars, queue/worker expectations, and migration safety notes in `README.md` or `docs/deploy.md`. - [x] T027 [US5] Add quick Sail commands and test invocation notes to `README.md` (e.g., `./vendor/bin/sail artisan test`) and ensure sample env entries for Graph credentials. ## Phase 8: User Story 6 - Tenant hinzufügen & Entra ID App-Setup (Priority: P1) - [x] T030 [US6] Migration für `tenants` ergänzen/prüfen: - Felder: `name`, `tenant_id` (GUID), `domain`, `app_client_id`, `app_status`, `app_notes`, `created_at`, `updated_at`. - Optional: Felder für Secret/Certificate-Config (verschlüsselt), falls benötigt. - [x] T031 [US6] Eloquent Model `Tenant`: - Beziehungen zu `policies`, `backup_sets`, `restore_runs`, `policy_versions`, `audit_logs` über `tenant_id`. - Tenant-aware Scopes, falls vorhanden (z. B. `forTenant()`). - [x] T032 [US6] Filament-Resource `TenantResource`: - Listenansicht: Name, Tenant ID, Domain, App-Status, erstellt/am. - Create/Edit-Form: Name, Tenant ID, Domain, App-Client-ID, optionale Notizen. - Detailseite mit Actions: - „Open in Entra“ (Link zur App/Tenant im Entra-Portal), - optional: „Copy Admin Consent URL“. - [x] T033 [US6] `TenantConfigService` (oder Erweiterung des Graph-Clients): - Methode `testConnectivity(Tenant $tenant)`: führt einen einfachen Graph-Call aus (z. B. `/organization` oder ähnliches) mit den App-Daten des Tenants. - Rückgabe: DTO/Array mit `success`, `error_message` (falls vorhanden). - [x] T034 [US6] Action „Verify configuration“ in `TenantResource`: - Ruft `testConnectivity()` auf, - setzt `app_status` auf z. B. `ok`, `error` oder `consent_required`, - zeigt eine Filament-Notification mit dem Ergebnis, - schreibt einen Audit-Log-Eintrag (`tenant.config.verified`). - [x] T035 [US6] Tenant-Kontext in bestehende Services integrieren: - `PolicySyncService`, `BackupService`, `RestoreService` so anpassen, dass sie einen `Tenant` oder `tenant_id` übergeben bekommen und den Graph-Client mit diesem Kontext verwenden. - Sicherstellen, dass alle policy/backup/restore/audit-Datensätze `tenant_id` setzen. - [x] T036 [US6] Feature-Test `TenantSetupTest`: - Erstellen eines Tenants via Filament (Create-Form). - Aufruf der Action „Verify configuration“ mit gemocktem Graph-Client: - einmal mit erfolgreichem Call → `app_status = ok`, - einmal mit Fehler → `app_status = error` + passende Notification. - Prüfen, dass Audit-Logs geschrieben werden. - [x] T037 [US6] Admin-Consent Callback Route - Route/Controller, der als `redirect_uri` der Entra-ID-App dient. - Liest `tenant` / `error` / `admin_consent` aus der Query. - Ordnet das dem richtigen `Tenant` zu (z. B. via `state`). - Aktualisiert `app_status` (z. B. `ok`, `error`, `consent_denied`). - Zeigt eine Bestätigungs-/Fehlerseite für den Admin. --- ## Phase 9: User Story 7 - Berechtigungsübersicht & Health-Status (Priority: P1) - [x] T040 [US7] Zentrale Permissions-Liste anlegen: - `config/intune_permissions.php` mit allen aktuell benötigten Graph-Berechtigungen: - technischer Name (z. B. `DeviceManagementConfiguration.ReadWrite.All`), - Typ: `application` / `delegated`, - kurze Beschreibung, - Feature-Tags (z. B. `["policy-sync", "backup"]`). - Optional: `docs/permissions.md` mit einer Tabelle Feature ↔ Permission als menschlich lesbare Referenz. - [x] T041 [US7] Datenmodell für Tenant-Berechtigungen: - Variante A (einfach): JSONB-Feld `granted_permissions` in `tenants` (Liste von Permission-Keys). - Variante B (feiner): Tabelle `tenant_permissions` mit `(tenant_id, permission_key, status, last_checked_at)`. - `status` mindestens: `ok`, `missing`, `error`. - [x] T042 [US7] Service `TenantPermissionService`: - `getRequiredPermissions(): array` – liest aus `config/intune_permissions.php`. - `getGrantedPermissions(Tenant $tenant): array` – liest aus Graph oder aus `tenant_permissions`/`granted_permissions`. - `compare(Tenant $tenant): TenantPermissionStatusDTO` – liefert pro Permission den Status (ok/missing/error) + Gesamthealth. - [x] T043 [US7] Integration in Tenant-Detail-UI: - Auf der `TenantResource`-Detailseite ein Panel/Section „Permissions“: - Liste aller **required permissions**, - pro Zeile: Name, Typ, Feature-Tags, Status (Icon + Label: OK/fehlt/Fehler). - Optional: Link zu Doku oder Entra-Darstellung (z. B. „How to grant these permissions“). - [x] T044 [US7] Action „Verify configuration“ erweitern: - Zusätzlich zu `testConnectivity()` auch `TenantPermissionService::compare()` aufrufen. - Ergebnisse in `tenant_permissions`/`granted_permissions` speichern. - `app_status` und Permission-Health aktualisieren. - Audit-Log-Eintrag `tenant.permissions.checked` schreiben. - [x] T045 [US7] Tests für Permissions: - Unit-Tests für `TenantPermissionService::compare()`: - Szenarien: alle ok, Permission fehlt, Graph-Error. - Feature-Test für Tenant-Detailseite: - required permissions werden angezeigt, - fehlende werden als fehlend markiert, - „Verify configuration“ aktualisiert den Status wie erwartet. ## Phase 9b: Scope-Ausrichtung auf neue Objekttypen - [x] T028 [Scope] Konfiguration `config/tenantpilot.php` auf die in `scope.supported_types` definierten Objekttypen erweitern (type/key, endpoint, label/category, optional risk/restore-Hinweis). Sicherstellen, dass diese Liste die einzige Quelle für Policy-Sync/Backup/Restore ist. - [x] T029 [Scope] Filament-UI an neue Typen anpassen: Tabellenfilter/Grouping nach Kategorie (z. B. Config/Compliance/Scripts/Apps/CA), Backup/Restore-Formulare mit Hinweisen zu Restore-Level aus `scope.restore_matrix` (z. B. CA/enrollment restrictions = preview-only). ## Phase 10: Housekeeping – Delete-Funktionen für Backups & Versions - [x] T060 [HK] BackupSets soft deletable machen: - `backup_sets` (und ggf. `backup_items`) Migration/Model mit `SoftDeletes` (deleted_at). - Sicherstellen, dass RestoreRuns keine gelöschten BackupSets verwenden; Delete nur erlauben, wenn keine zugehörigen RestoreRuns existieren. - [x] T061 [HK] Filament-Delete-Action für BackupSets: - In `BackupSetResource` Delete-Action in List- und/oder Detail-View hinzufügen. - Mit Confirmation-Dialog (“This will archive this backup set and hide it from the UI.”). - Delete disabled/hidden, wenn `restore_runs` für das Set existieren. - Nach Delete Audit-Log (`backup.deleted`) schreiben. - [x] T062 [HK] PolicyVersions soft deletable machen: - `policy_versions` Migration/Model um `SoftDeletes` erweitern. - Alle Queries und Filament-Resources so lassen, dass standardmäßig nur non-deleted Versions angezeigt werden. - [x] T063 [HK] Filament-Delete-Action für PolicyVersions: - In `PolicyVersionResource` Delete-Action hinzufügen (List/Detail). - Confirmation + Audit-Log (`policy_version.deleted`). - [x] T064 [HK] Tests für Housekeeping: - Feature-Test: Löschen eines BackupSets ohne RestoreRun → `deleted_at` gesetzt, UI-Eintrag weg, Audit-Log vorhanden. - Feature-Test: BackupSet mit RestoreRun → Delete-Action nicht verfügbar. - Feature-Test: Löschen einer PolicyVersion → `deleted_at` gesetzt, nicht mehr in List sichtbar. ## Phase 11: Housekeeping – Tenant löschen/deaktivieren - [x] T070 [HK] Tenants soft deletable machen: - `tenants` Model um `SoftDeletes` erweitern, Migration ggf. `deleted_at` hinzufügen. - Optional: Feld `status` (enum/string: `active`, `archived`) einführen; beim Delete auf `archived` setzen. - Alle Standard-Queries für Tenants nur `active` / nicht gelöscht anzeigen. - [x] T071 [HK] Tenant-Delete-Action (Deaktivieren) in `TenantResource`: - Delete-/Archive-Action in der Tenant-Liste und/oder Detailseite hinzufügen. - Deutlich machen: “Deaktiviert diesen Tenant. Historische Daten bleiben vorhanden, neue Aktionen sind nicht mehr möglich.” - Bei Ausführung: - `deleted_at` setzen (und ggf. `status = archived`), - Audit-Log `tenant.deleted` oder `tenant.archived` schreiben. - [x] T072 [HK] Verhalten für deaktivierte Tenants: - In `PolicySyncService`, `BackupService`, `RestoreService` prüfen, dass nur aktive Tenants verwendet werden; bei deaktiviertem Tenant frühzeitig mit verständlicher Fehlermeldung abbrechen. - In Filament-Navigation Tenants, Policies, Backups, Restores eines deaktivierten Tenants nicht mehr in Standard-Listen anzeigen (es sei denn, es gibt explizite “Show archived”-Filter). - [x] T073 [HK] (Optional) RestoreRuns soft deletable machen: - `restore_runs` Model/Migration mit `SoftDeletes`. - Delete-Action in `RestoreRunResource` hinzufügen (nur UI-Aufräumung, keine Folgen für Backups). - Audit-Log `restore_run.deleted` schreiben. - [x] T074 [HK] Tests für Tenant-Delete: - Feature-Test: Tenant löschen/deaktivieren → Tenant taucht nicht mehr in Standardlisten auf, `deleted_at` (und `status`) ist gesetzt, Audit-Event existiert. - Feature-Test: Versuch, mit deaktiviertem Tenant einen Policy-Sync/Backup/Restore zu starten, führt zu einem klaren Fehler (und kein Graph-Call wird ausgeführt). ## Phase 12: Housekeeping – Hard Deletes (Force Delete) - [x] T075 [HK] Force-Delete-Actions ergänzen: - Filament-Listen für Tenants, BackupSets, PolicyVersions, RestoreRuns erhalten „Force delete“ Aktionen (sichtbar nur im Trashed-Filter), mit klarer Confirmation. - BackupSets: Force delete nur, wenn keine RestoreRuns existieren; löscht Items mit. - Tenants: Force delete nur, wenn archiviert; blockiert für aktive Tenants. - Alle Force-Deletes schreiben Audit-Log-Einträge vor der endgültigen Löschung. - Tests für Force-Delete-Flows (erfolgreich/blockiert) ergänzen. ## Phase 12: Single current tenant ("Highlander") - [x] T120 [TENANT] Migration `add_is_current_to_tenants`: - Spalte `is_current` (boolean, default false, not null) zu `tenants` hinzufügen. - Partielle Unique-Index anlegen, z. B.: - `UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL`. - [x] T121 [TENANT] Tenant-Model anpassen: - Methode `makeCurrent()` implementieren: - Transaktion: alle anderen Tenants `is_current = false`, dieser Tenant `is_current = true`. - Methode `static current()` implementieren: - Wenn `INTUNE_TENANT_ID` gesetzt ist → Tenant mit dieser GUID laden, sonst Exception, wenn nicht gefunden / deaktiviert. - Wenn nicht gesetzt → Tenant mit `is_current = true` und `status = active` (und `deleted_at` null) zurückgeben. - Wenn keiner → Exception “No current tenant selected”. - `findOrCreateDefault()` deprecaten/entfernen; keine Dummy-Tenants mehr erzeugen. - [x] T122 [TENANT] Data-Migration / Cleanup: - Falls mindestens ein Tenant mit `app_status = ok` existiert: - einen als `is_current = true` markieren (z. B. den ersten). - `local-tenant` auf `status = archived`, `is_current = false` setzen. - Sicherstellen, dass `local-tenant` nie wieder als aktueller Kontext verwendet wird. - [x] T123 [TENANT] Filament `TenantResource` UI: - Spalte/Badge für `is_current` in der Liste hinzufügen. - Table-Action "Make current" ergänzen: - nur sichtbar für aktive Tenants, die nicht `is_current` sind. - ruft `makeCurrent()` auf und zeigt Notification. - Alte Logik entfernen, die `local-tenant` automatisch als Default nutzt. - [x] T124 [TENANT] Consumers refactoren: - Alle Vorkommen von `findOrCreateDefault()` suchen und durch `Tenant::current()` (oder expliziten Tenant) ersetzen: - Policy-Sync (Command + Filament-Action), - BackupSet-Erstellung, - RestoreRun-Erstellung, - ggf. weitere Services. - [x] T125 [TENANT] Tests: - Unit-Tests für `Tenant::current()`: - INTUNE_TENANT_ID gesetzt → nimmt diesen Tenant, Fehler wenn nicht vorhanden. - INTUNE_TENANT_ID nicht gesetzt → nimmt den mit `is_current = true`. - kein current Tenant → Exception. - Feature-Test für "Make current" in `TenantResource`: - Nach der Action ist genau ein Tenant `is_current = true`, alle anderen `false`. - Optional: Test, dass `local-tenant` nach Cleanup nicht mehr als Kontext gewählt wird. - [x] T130 [UX] Tabellen-Aktionen in Dropdown bündeln (ActionGroup) - In `TenantResource` (Tenants-Liste) die Zeilen-Aktionen refaktorieren: - `View` (optional) direkt anzeigen. - Alle weiteren Aktionen (`Edit`, `Admin consent`, `Verify configuration`, `Deactivate`, `Force delete`) in eine `Tables\Actions\ActionGroup` mit "⋯"-Icon verschieben. - Prüfen, ob in anderen Ressourcen mit vielen Row-Actions (z.B. Backups, RestoreRuns) ebenfalls eine `ActionGroup` sinnvoll ist und diese konsistent einsetzen. ## Phase 13: Settings Normalization & Display (Priority: P1) **User Story**: US1b - "Admin can open a policy detail page and see the **effective Intune settings** in a readable, normalized way (not raw JSON dumps)" **Prerequisites**: Phase 3 (Policy inventory) complete, PolicyResource and PolicyVersionResource exist **Scope**: FR-019 / FR-019a / FR-019b, Edge-001 (malformed snapshots), Edge-002 (@odata.type mismatch), NFR-006 (normalization coverage) per .specify/plan.md dated 2025-12-10. ### Tests for User Story 1b - [x] T140 [P] [US1b] Unit test for PolicyNormalizer service in `tests/Unit/PolicyNormalizerTest.php`: - Test OMA-URI/custom policy transformation → path/value table structure - Test Settings Catalog policy transformation → flattened key-value structure - Test standard object transformation → labeled key-value with metadata filtered - Test edge case: malformed snapshot (keys lost) → returns warning indicator - Test edge case: @odata.type mismatch detection → returns mismatch flag - [x] T141 [P] [US1b] Feature test for Settings section in Policy detail view in `tests/Feature/Filament/PolicySettingsDisplayTest.php`: - Create policy with sample JSONB snapshot (OMA-URI, Settings Catalog, standard object) - Visit Policy detail page via PolicyResource - Assert "Settings" section exists using Infolist component - Assert normalized settings are displayed (not raw JSON) - Assert metadata keys (@odata.type, internal IDs) are hidden - [x] T142 [P] [US1b] Feature test for pretty JSON + normalized settings in Version detail in `tests/Feature/Filament/PolicyVersionSettingsTest.php`: - Create policy version with JSONB snapshot - Visit PolicyVersion detail page via PolicyVersionResource - Assert pretty-printed JSON exists (monospace, copyable format) - Assert normalized settings section exists below JSON - Assert both views show same data in different formats - [x] T143 [P] [Edge] Feature test for malformed snapshot warning in `tests/Feature/Filament/MalformedSnapshotWarningTest.php`: - Create policy version with malformed snapshot (array-only, keys lost) - Visit detail pages (Policy and PolicyVersion) - Assert UI warning displayed: "This snapshot may be incomplete or malformed" - Assert partial settings display attempted with warning badge - [x] T144 [P] [Edge] Feature test for @odata.type mismatch flag in `tests/Feature/Filament/ODataTypeMismatchTest.php`: - Create policy with @odata.type that doesn't match expected platform/type mapping - Visit Policy detail page - Assert warning badge/banner displayed - Create restore run with mismatched policy → assert validation error prevents execution ### Implementation for User Story 1b - [x] T145 [US1b] Create PolicyNormalizer service in `app/Services/Intune/PolicyNormalizer.php`: - Method `normalize(array $snapshot, string $policyType): array`: - Returns `['status' => 'success|warning|error', 'settings' => [...], 'warnings' => [...]]` - Implement OMA-URI transformation: - Extract `omaSettings` array → convert to `[['path' => '...', 'value' => '...']]` table - Filter metadata keys (@odata.type, id, version, etc.) - Implement Settings Catalog transformation: - Flatten nested `settings` or `settingsDelta` structures - Convert to labeled key-value pairs with categories - Implement standard object transformation: - Extract top-level properties (displayName, description, etc.) - Group by logical sections (General, Assignment, Advanced) - Hide Graph-specific metadata - Add edge case detection: - Check for malformed snapshots (empty keys, string-only serialization) - Check for @odata.type mismatch with policy type - Return warnings array with human-readable messages - [x] T146 [US1b] Add Settings Infolist section to PolicyResource ViewPolicy page in `app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`: - Override `infolist()` method to add "Settings" section - Use `Infolist\Components\Section` for grouping - Call `PolicyNormalizer::normalize()` with policy's latest snapshot - Render normalized settings using: - `TextEntry` for simple key-value pairs - `RepeatableEntry` for tables (OMA-URI paths) - `KeyValueEntry` for nested structures - Display warnings if normalization returns warning status - Add fallback: "No settings available" if snapshot missing/empty - [x] T147 [US1b] Enhance PolicyVersionResource ViewPolicyVersion with pretty JSON component in `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`: - Add "Snapshot" section with two subsections: 1. **Raw JSON** (collapsible): - Use `ViewEntry` with custom view blade - Format JSON with `json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)` - Wrap in `
` for syntax highlighting
       - Add "Copy" button using Filament action
    2. **Normalized Settings**:
       - Call `PolicyNormalizer::normalize()` with version snapshot
       - Render same structure as T146 (reuse component if possible)
  - Handle edge case: malformed snapshot → show warning above both views

- [x] T148 [US1b] Integrate PolicyNormalizer output into existing views:
  - Update `PolicyResource` list table: add hint column showing "Settings available" badge
  - Update `PolicyVersionResource` diff view: optionally show normalized diff (if feasible)
  - Ensure tenant-scoped queries when fetching policies for normalization
  - Add caching consideration: normalize on-demand, don't store normalized output

- [x] T149 [Edge] Add snapshot validation helper in `app/Services/Intune/SnapshotValidator.php`:
  - Method `validate(array $snapshot): ValidationResult`:
    - Check for empty/null snapshot → return error
    - Check for array-only structure (keys lost) → return warning
    - Check for string-only serialization → return error
    - Check for required Graph keys (@odata.type, displayName) → return warning if missing
  - Return structured result with:
    - `isValid: bool`
    - `warnings: array`
    - `errors: array`
  - Use in PolicyNormalizer before transformation

- [x] T150 [Edge] Add @odata.type validator in Policy/BackupItem models:
  - Add static method `Policy::validateODataType(array $snapshot, string $expectedType): bool`
  - Check if `$snapshot['@odata.type']` matches expected Graph type for policy platform/type
  - Create mapping array:
    ```php
    'deviceConfiguration' => [
        'windows' => '#microsoft.graph.windowsConfiguration',
        'ios' => '#microsoft.graph.iosConfiguration',
        // etc.
    ]
    ```
  - Return bool + optional error message
  - Call in BackupService before storing backup item
  - Call in RestoreService before executing restore (gate check)

- [x] T151 [Edge] Display warnings in Filament UI for malformed/mismatched data:
  - Update `PolicyResource/ViewPolicy` infolist:
    - Add `Placeholder` component with warning icon + message if validation fails
    - Show "⚠️ This snapshot may be incomplete or malformed" banner
  - Update `PolicyVersionResource/ViewPolicyVersion`:
    - Add warning badge next to "Snapshot" section title if malformed
  - Update `RestoreRunResource` preview/execution:
    - Add validation step that checks all selected items for @odata.type mismatch
    - Show modal with list of problematic items + "Cancel" / "Proceed anyway" options
    - Log validation warnings to audit log
  - Ensure warnings are visible but not blocking (except for restore execution gate)

### Documentation for Phase 13

- [x] T152 [US1b] Update `README.md` with Settings normalization feature:
  - Add section "Policy Settings Display" explaining:
    - Normalized views for better readability
    - Supported policy types (OMA-URI, Settings Catalog, standard objects)
    - Edge case handling (malformed snapshots, type mismatches)
  - Add screenshot/example of Settings section in Policy detail

- [x] T153 [US1b] Add inline documentation in PolicyNormalizer:
  - PHPDoc blocks for all public methods
  - Examples of input/output structures in comments
  - Edge case handling notes

**Phase 13 Completion Criteria**:
- All 14 tasks (T140-T153) completed
- Tests passing for normalization logic and UI display
- Policy detail shows readable Settings section
- Version detail shows pretty JSON + normalized settings
- Edge cases handled with clear warnings
- Constitution Check: still 7/7 ✅ (auditability maintained, safety-first for restore gates)

## Phase 14: User Story 8 – Intune RBAC Onboarding Wizard (Delegated, Synchronous)

**Scope**: FR-023 to FR-030; delegated login and grant run synchronously in Filament (no queue for grant). Optional jobs/CLI only for CHECK/REPORT (no grant).

- [ ] T160 [US8] Add TenantResource ActionGroup entry “Setup Intune RBAC”: visible for active tenants with `app_client_id`; sits alongside Admin consent/Verify; guarded by Highlander rules (no deactivated tenants).
- [ ] T161 [US8] Wizard UI (Filament): Step 1 preconditions/summary with inputs for Role (default Policy and Profile Manager, warn on Intune Admin), Scope (All devices or scope group), Group mode (create default TenantPilot-Intune-RBAC vs select existing security-enabled group); surface least-privilege warning for production.
- [ ] T162 [US8] Delegated auth step: initiate delegated login for selected tenant; stop with clear error + audit on failure/denied consent; short-lived token only (no storage).
- [ ] T163 [US8] Execution service (synchronous) with audit per step: resolve service principal by `app_client_id`; ensure/create security group; add SP as member (idempotent); ensure/create/update Intune role assignment for chosen role/scope using Graph abstraction; no queue usage.

- [ ] T164 [US8] Post-check (mandatory): force fresh token acquisition (clear tenant token cache) and run canary reads:
  - `GET /deviceManagement/deviceConfigurations?$top=1`
  - `GET /deviceManagement/deviceCompliancePolicies?$top=1`
  - Optional: `GET /identity/conditionalAccess/policies?$top=1` only if CA features are enabled
  - Update `app_status`/permissions health + write audit entries (start/login/group/member/assignment/verify outcomes).

- [ ] T165 [US8] Tests (Pest, mocked Graph): happy path; rerun idempotent (no duplicates); missing permissions → clear error mapping; scope-limited selection → warning surfaced; delegated login failure path.

- [ ] T166 [US8] Documentation: README note for wizard behavior (delegated, synchronous), least-privilege defaults, audit expectations, and how to rerun safely.

- [ ] T167 [US8-Optional] CLI/Job for CHECK/REPORT only (no grant) to inspect RBAC state; explicitly exclude grant actions from async/queue.

- [ ] T168 [US8] Extend Verify configuration / Health panel to include “Intune RBAC status”:
  - Determine whether the configured Enterprise App (service principal) is covered by an Intune role assignment for required scopes (OK / Missing / Error).
  - Show actionable message (“Run Setup Intune RBAC”) when missing.
  - Persist last_checked_at + short reason code; log audit `tenant.rbac.checked`.

- [ ] T169 [US8] Persist RBAC artifacts on Tenant for stable idempotency:
  - Add nullable columns to `tenants`: `rbac_group_id`, `rbac_role_assignment_id`, `rbac_role_key`, `rbac_scope_mode`, `rbac_scope_id`.
  - Prefer stored IDs on reruns; fall back to discovery only if missing.
  - Add migration + model casts; include IDs in audit context (IDs only; no secrets).