diff --git a/.specify/plan.md b/.specify/plan.md index b641e8e..4c5bd0b 100644 --- a/.specify/plan.md +++ b/.specify/plan.md @@ -1,70 +1,53 @@ # Implementation Plan: TenantPilot v1 -**Branch**: `tenantpilot-v1` | **Date**: 2025-12-10 | **Spec**: .specify/spec.md -**Input**: Feature specification from `.specify/spec.md` +**Branch**: `tenantpilot-v1` +**Date**: 2025-12-12 +**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged) ## Summary +TenantPilot v1 already delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup/permissions health, settings normalization/display, and Highlander enforcement. Remaining priority work is the delegated Intune RBAC onboarding wizard. All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates for high-risk types (preview-only). -TenantPilot v1 delivers admin-facing Intune inventory across the scoped object types (config, compliance, scripts, apps, CA, endpoint security, enrollment/autopilot), immutable backups, version history with diffs, and defensive restore (per-type restore levels from the matrix: enabled vs preview-only) in Filament. Data is tenant-aware with JSONB snapshots, Graph access is isolated behind an abstraction, and operations are audited. Local development uses Sail with PostgreSQL; deployments go through Dokploy staging before production. +## Status Snapshot (tasks.md is source of truth) +- **Done**: US1 inventory, US2 backups, US3 versions/diffs, US4 restore preview/exec, scope config, soft-deletes/housekeeping, Highlander single current tenant, tenant setup & verify (US6), permissions/health overview (US7), table ActionGroup UX, settings normalization/display (US1b), Dokploy/Sail runbooks. +- **Next up**: US8 (formerly labeled “User Story 7” in spec) Intune RBAC onboarding wizard (delegated, synchronous Filament flow). -## Technical Context +## Technical Baseline +- Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL. +- JSONB for policy/backup/version payloads; FK/time indexes, GIN where needed. +- Graph abstraction with standardized error mapping/retries; no secrets in logs. +- Audit trail across backup/restore/version/tenant/permission/wizard steps; tenant isolation enforced. +- Restore matrix and supported types remain config-driven single sources of truth. +- Safety: preview/dry-run, confirmation gates, warnings for high-risk types; no implicit tenants (Highlander). -**Language/Version**: PHP 8.2+, Laravel 12, Filament 4 -**Primary Dependencies**: Laravel framework, Filament admin, Pest, Laravel Sail, Vite/Tailwind 4 for assets -**Storage**: PostgreSQL (JSONB for policy snapshots, backups, versions) -**Testing**: Pest via `./vendor/bin/sail artisan test` (graph boundaries mocked) -**Target Platform**: Containerized web app on Dokploy (Staging → Production), Filament admin UI -**Project Type**: Web (Laravel monolith with Filament) -**Performance Goals**: Admin portal responsiveness (<1s typical page loads), Graph calls rate-limit aware; migrations avoid long locks -**Constraints**: Safety-first ops (preview/confirm, audit), reversible migrations validated on staging, tenant-scoped queries, no secrets in code, JSONB retention to avoid unbounded growth; per-type restore behavior follows `scope.restore_matrix` (e.g., CA/enrollment restrictions = preview-only) -**Scale/Scope**: Single-tenant deployment (tenant-aware schema), multi-type coverage driven by `scope.supported_types` + restore matrix +## Completed Workstreams (no new action needed) +- **US1 Inventory (Phase 3)**: Filament policy listing with type/category/platform filters; tenant-scoped. +- **US2 Backups (Phase 4)**: Backup sets/items in JSONB, immutable snapshots, audit logging, relation manager UX for attaching policies, soft-delete rules with restore-run guard. +- **US3 Versions/Diffs (Phase 5)**: Version capture, timelines, human+JSON diffs, soft-deletes with audit. +- **US4 Restore (Phase 6)**: Preview, selective execution, conflict warnings, per-type restore level (enabled vs preview-only), PowerShell decode/encode respected, audit of outcomes. +- **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants. +- **US7 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail. +- **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed/@odata mismatches, normalized settings and pretty JSON on policy/version detail, list badges, README section. +- **Housekeeping/UX (Phases 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline. +- **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured. -## Constitution Check +## Next Up: US8 Intune RBAC Onboarding Wizard (delegated, synchronous) +- Entry: Tenant detail ActionGroup “Setup Intune RBAC”; gated to active tenants with `app_client_id`. +- Flow: explain/preconditions (role/scope/group mode, least-privilege warning), delegated login, synchronous execution in Filament (no queue for grant), post-check via Verify + canary reads. + - Canary reads (read-only): `GET /deviceManagement/deviceConfigurations?$top=1` and `GET /deviceManagement/deviceCompliancePolicies?$top=1` (and `GET /identity/conditionalAccess/policies?$top=1` only if CA is enabled for the tenant/scope). +- Execution steps (idempotent): resolve service principal; ensure/create security group; add SP member; create/update role assignment with chosen scope; log audit for start/login/group/member/assignment/verify. +- Optional jobs/CLI limited to CHECK/REPORT only (no grant). +- Tests: happy path, rerun idempotent, missing permissions error mapping, scope-limited warning. +- Documentation: add wizard behavior, audit expectations, and least-privilege guidance once implemented. +- Operational note: After admin-consent or RBAC changes, force a fresh token acquisition (e.g., clear app token cache) before re-trying sync/backup/restore; Verify should run with a non-stale token. +- Note: This is **Intune RBAC** for the **Enterprise App (service principal)**. No “App roles” need to be added in the App Registration; Graph API permissions + Intune role assignment are separate concerns. -- Safety-First Operations: Restore/rollback flows require preview + explicit confirmation; conflict warnings surfaced; dry-run supported. ✅ -- Immutable Versioning: Policy versions/backups stored as immutable JSONB snapshots; no in-place edits; diffs available. ✅ -- Defensive Restore: Preview/dry-run, selective items, conflict detection, pre-execution summary, confirmation gate. ✅ -- Auditability: Backup creation, version capture, restore start/result (success/failure/partial) audited with tenant scoping. ✅ -- Tenant-Aware Architecture: Tenant entity present; all policy/version/backup/restore/audit records reference tenant context; queries enforce isolation. ✅ -- Graph Abstraction: All Graph calls through a dedicated adapter/service with standardized error mapping and logging (no direct Graph in UI/domain). ✅ -- Spec-Driven Development: Spec + plan present in `.specify/`; tasks to follow; constitution check complete before implementation. ✅ +## Testing & Quality Gates +- Continue using targeted Pest runs per change set; add/extend tests for US8 accordingly. +- Run Pint on touched files before finalizing. +- Maintain tenant isolation, audit logging, and restore safety gates; validate @odata.type and malformed snapshots prior to restore execution. +- Safety gate: `@odata.type` mismatches MUST block restore execution (preview may still show details + warnings), to prevent applying payloads to the wrong policy type/platform. -## Project Structure - -### Documentation (this feature) - -```text -.specify/ -├── spec.md # Feature specification (v1 scope) -├── plan.md # This plan -└── tasks.md # To be generated from plan/spec -``` - -### Source Code (repository root) - -```text -app/ -├── Models/ -├── Http/Controllers/ -├── Filament/ # Admin resources/pages/widgets -├── Services/Graph/ # Graph abstraction layer (planned) -├── Services/Intune/ # Domain orchestration (planned) -├── Actions/Jobs/Events/ # Async and domain events -database/ -├── migrations/ -├── seeders/ -resources/ -├── views/ # Blade/Vite-driven assets -routes/ -├── web.php -├── console.php -tests/ -├── Feature/ # Filament + flow coverage -└── Unit/ -``` - -**Structure Decision**: Single Laravel monolith with Filament admin. Tenant-aware data stored in PostgreSQL JSONB; Graph access isolated under `app/Services/Graph/`; domain services for backups/versions/restores live under `app/Services/Intune/`. - -## Complexity Tracking - -No constitution violations; complexity tracking not required. +## Coordination +- Update `.specify/tasks.md` to reflect progress on remaining US8 tasks; no new entities or scope changes introduced here. +- Stage validation required before production for any migration or restore-impacting change. +- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). diff --git a/.specify/spec.md b/.specify/spec.md index dd8f100..f07ed90 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -390,4 +390,256 @@ ### UX Guideline – Table Actions / Dropdowns und Admins bekommen eine konsistente "⋯"-Interaktion für erweiterte Aktionen. + +## User Story 7 – Intune RBAC Onboarding Wizard (Delegated Admin Login) *(Priority: P1)* + +### Problem / Context + +TenantPilot arbeitet primär **app-only (Client Credentials)** gegen Microsoft Graph. +Für viele Intune-Objekte reicht „Graph App Permissions + Admin Consent“ allein nicht aus: Intune kann zusätzlich über **Intune RBAC** +blockieren, wenn der **Service Principal** (Enterprise App) keine passende **Intune Role Assignment** inkl. Scope hat. +Das äußert sich typischerweise als **403** mit „Application is not authorized to perform this operation“. + +Dieses Setup ist ein **Bootstrap-Problem**: +- Ohne RBAC-Zuweisung sind Intune Reads/Writes blockiert. +- Ohne ausreichende Rechte kann TenantPilot die RBAC-Zuweisung nicht „self-service“ per app-only herstellen. + +**Ziel:** TenantPilot bietet pro Tenant einen **Onboarding-Wizard**, bei dem ein Admin sich **interaktiv (delegated)** anmeldet, +und TenantPilot automatisiert (idempotent) die erforderliche Intune-RBAC-Konfiguration für die konfigurierte Enterprise App herstellt. +Danach funktionieren Policy Sync / Backup / Restore (gemäß Restore Matrix) zuverlässig. + +--- + +### User Value + +- Admins können RBAC-Probleme direkt in TenantPilot beheben (kein “Portal-Rätselraten”). +- Klarer, auditierter Ablauf (wer hat wann welche Rechte/Sopes gesetzt). +- Minimiert Ausfälle bei Policy-Sync/Backup/Restore und reduziert Support-Aufwand. + +--- + +### In Scope (v1) + +- Wizard in Filament auf der **Tenant-Detailseite** (tenant-scoped). +- Delegated Admin Login (interaktiv). +- Idempotente Ausführung: + - Service Principal (zu `tenant.app_client_id`) auflösen + - RBAC-Membership via **Security Group (recommended)** herstellen + - Intune Role Assignment erstellen/aktualisieren (Rolle + Scope) + - Abschließender Verify-Run (Health/Permissions aktualisieren) +- Vollständige Audit-Logs pro Step. + +--- + +### Out of Scope (v1) + +- Vollautomatisches “Self-heal” ohne Admin-Interaktion. +- Zeitgesteuerte Jobs, die RBAC-Rechte vergeben (ohne explizite Admin-Aktion). +- Unterstützung mehrerer paralleler RBAC-Profile pro Tenant (nur ein “recommended setup”). + +--- + +## UX / Entry Points + +### Entry Point: Tenant Detail (Filament) + +Auf der `TenantResource` Detailseite im Action-Dropdown: + +- `Setup Intune RBAC` (Wizard) +- `Admin consent` +- `Verify configuration` + +**Visibility rules:** +- Nur für `status=active` Tenants. +- Nur wenn `app_client_id` gesetzt ist. +- Optional: Badge/Hint “RBAC missing” aus Health-Check. + +**Copy/Help:** +- Kurze Erklärung: “Graph Permissions ≠ Intune RBAC”. +- Hinweis auf Least Privilege. +- Klarer Hinweis, dass Änderungen tenantweit wirken (je nach Scope). + +--- + +## Wizard Flow + +### Step 1 — Configuration (Role / Scope / Group) + +**Inputs:** +- **Role** (Dropdown): + - Default: `Policy and Profile Manager` (Least Privilege für Policy/Config-Workflows) + - Optional: `Intune Administrator` (mit Warnung) +- **Scope** (Dropdown): + - Default: `Global / All devices` (wenn verfügbar) + - Optional: Auswahl einer Scope Group / Device Group (falls euer Modell das nutzt) +- **Group Mode**: + - Default: `Use Security Group (recommended)` + - Options: + - `Create new group` (Default-Name: `TenantPilot-Intune-RBAC`) + - `Use existing group` (Picker) + +**UI Requirements:** +- “Review screen” zeigt *genau*, was erstellt/geändert wird (Role, Scope, Group). + +### Step 2 — Delegated Admin Login + +- Admin führt interaktiven Login durch (delegated). +- Wizard zeigt klar: + - welcher Tenant + - welche App (Client ID / Display Name, sofern auflösbar) + - dass nur kurzzeitig ein User-Token genutzt wird + +**Security rule (mandatory):** +- Delegated Access Tokens werden **nicht persistiert** (keine Speicherung in DB/Cache). +- Tokens existieren nur im Request-Kontext / Session und werden nach Abschluss verworfen. + +### Step 3 — Execute Setup (Idempotent + Safe) + +Wizard führt folgenden Ablauf aus (alle Operationen tenant-scoped, über Graph-Abstraktion, mit Error-Mapping): + +1) **Resolve Service Principal** +- Auflösen des Service Principals zur `tenant.app_client_id`. +- Wenn nicht gefunden: + - Wizard stoppt mit Hinweis: “Enterprise App ist im Tenant nicht vorhanden. Bitte zuerst Admin Consent durchführen.” + - Audit log: `tenant.rbac.setup.failed` (reason: sp_not_found) + +2) **Ensure Security Group** +- Falls “Create new group”: + - Security Group erstellen (securityEnabled=true, mailEnabled=false). + - Wenn bereits vorhanden (gleiches displayName): wiederverwenden (oder per gespeicherter `rbac_group_id`). +- Falls “Use existing group”: + - Validieren: `securityEnabled=true`. +- Ergebnis-IDs werden gespeichert: + - `tenants.rbac_group_id` (neu, optional) + - `tenants.rbac_group_name` (optional, nur für UX) + +3) **Ensure Membership (SP ∈ Group)** +- Service Principal als Member hinzufügen, wenn nicht vorhanden. +- Konflikte (already exists) müssen als OK behandelt werden. + +4) **Ensure Intune Role Assignment** +- Suche nach existierendem Role Assignment, das: + - die gewünschte RoleDefinition referenziert + - die Group als Member enthält + - den gewünschten Scope abdeckt +- Wenn vorhanden: **Patch/Update** (z. B. Scope ergänzen) +- Wenn nicht vorhanden: **Create** Role Assignment + +5) **Post-Verify (mandatory)** +- Direkt nach Setup: + - `Verify configuration` ausführen (inkl. Permission-Matrix Update) + - Zusätzlich 1–2 “Canary Calls” gegen Intune-Endpunkte, die für v1 kritisch sind (Read-Only reicht). +- Ergebnisse werden in Tenant-Health gespeichert (`app_status`, permissions health). + +**Execution mode (v1):** +- Wizard führt die Steps synchron aus (kein Queue-Job), um Token-Probleme zu vermeiden. +- Timeouts: klare Fehlermeldung + Audit. + +### Step 4 — Summary + +- Wizard zeigt: + - Group (Name + ObjectId) + - Role (Name) + - Scope (global / group id) + - RoleAssignmentId (falls verfügbar) + - Verify result (OK / Partial / Failed) +- CTA: “Retry policy sync” + +--- + +## Data Model Additions (minimal) + +Erweiterung `tenants` (optional aber empfohlen, für Transparenz & Idempotenz): + +- `rbac_group_id` (nullable string/GUID) +- `rbac_role_assignment_id` (nullable string/GUID) +- `rbac_role_key` (nullable string; z. B. `policy_profile_manager`) +- `rbac_scope_mode` (nullable string; z. B. `global|group`) +- `rbac_scope_id` (nullable string/GUID) + +> Hinweis: Wenn ihr strikt ohne zusätzliche Felder arbeiten wollt, geht es auch rein über Discovery, +> aber gespeicherte IDs machen den Wizard deutlich stabiler und schneller. + +--- + +## Functional Requirements (additions) + +- **FR-023**: System MUST expose a per-tenant onboarding wizard “Setup Intune RBAC” in Filament. +- **FR-024**: Wizard MUST use delegated admin login and MUST NOT store delegated tokens. +- **FR-025**: Wizard MUST be idempotent (re-run safe) and MUST converge to the desired RBAC state. +- **FR-026**: Wizard MUST support group-based RBAC membership (recommended) and MUST ensure the service principal is a member. +- **FR-027**: Wizard MUST create or update Intune role assignments with an explicit role + scope. +- **FR-028**: Wizard MUST run a post-setup verification that updates tenant health and permissions UI. +- **FR-029**: Wizard MUST write audit logs for start, each step outcome, and final result (success/failed/partial). +- **FR-030**: Wizard MUST enforce tenant isolation and use explicit tenant context (no implicit defaults). + +--- + +## Non-Functional / Safety Requirements + +- Least Privilege: + - Default role selection is non-global admin (Policy/Profile manager). + - Selecting higher-privilege roles shows a warning and requires explicit confirmation. +- Clear Failure UX: + - Every failure must map to an actionable message (e.g., “Admin consent missing”, “Insufficient directory permissions”). +- No secrets: + - No access tokens, secrets, or payloads in logs/audits. +- Deterministic logging: + - Audit entries include tenant_id, actor_user_id, action_key, resource IDs (group, roleAssignment), and status. + +--- + +## Acceptance Scenarios + +1) **Missing RBAC → Wizard fixes it** +- **Given** ein aktiver Tenant mit konfigurierter App (`app_client_id`) und Admin Consent, + aber Intune Calls liefern RBAC-403, +- **When** der Admin den Wizard ausführt, +- **Then** wird Gruppe+Membership+RoleAssignment hergestellt, Verify wird OK, und Policy Sync funktioniert. + +2) **Admin Consent fehlt** +- **Given** `app_client_id` ist gesetzt, aber der Service Principal kann nicht aufgelöst werden, +- **When** Wizard startet, +- **Then** bricht er mit “Bitte zuerst Admin Consent durchführen” ab und schreibt Audit `sp_not_found`. + +3) **Idempotenz** +- **Given** Wizard wurde bereits erfolgreich ausgeführt, +- **When** Wizard erneut mit gleichen Einstellungen ausgeführt wird, +- **Then** werden keine Duplikate erzeugt, und die Summary zeigt “No changes / Already compliant”. + +4) **Insufficient privileges** +- **Given** ein Admin loggt sich ein, aber hat nicht die nötigen Rechte, +- **When** Setup ausgeführt wird, +- **Then** stoppt der Wizard mit klarer Fehlermeldung pro Step (z. B. group create / role assignment create), + und Audit enthält den Step und Fehlercode. + +5) **Restricted scope** +- **Given** Admin wählt eine eingeschränkte Scope Group, +- **When** Setup abgeschlossen ist, +- **Then** Verify markiert ggf. “Partial” mit Hinweis “Inventory limited by scope”. + +--- + +## Implementation Notes (for plan.md linkage) + +- Reuse existing services pattern: + - `IntuneRbacSetupService` (new) in `app/Services/Intune/` + - Uses `GraphClientInterface` and existing error mapping/logging hooks. + - Uses `AuditLogger` for stepwise audit events. +- Extend `TenantPermissionService` (User Story 7 in tasks) to include an RBAC check state: + - status: `ok|missing|error` + - message: “Intune RBAC role assignment missing (Wizard required)” +- Add Filament wizard page/action under `TenantResource`. + +--- + +## Edge Cases + +- Group exists but is not security-enabled → fail with actionable message. +- Role assignment exists but wrong scope → patch and warn. +- Multiple “similar” groups by name → prefer stored `rbac_group_id` if present, else prompt. +- Tenant mismatch: Wizard must never operate on non-selected tenant (enforce `Tenant::current()` or explicit tenant param). +- Token expiry mid-run → show “Please retry” + audit partial. + + Previous draft archived under spechistory/spec.md \ No newline at end of file diff --git a/.specify/tasks.md b/.specify/tasks.md index 5118988..c66173b 100644 --- a/.specify/tasks.md +++ b/.specify/tasks.md @@ -8,6 +8,10 @@ # 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/`. @@ -316,3 +320,187 @@ ## Phase 12: Single current tenant ("Highlander") - 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).
diff --git a/README.md b/README.md
index de2e643..0069a69 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,15 @@ ## TenantPilot setup
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
- Keep secrets/env in Dokploy, never in code.
+## Policy Settings Display
+
+- Policy detail pages render normalized settings instead of raw JSON:
+ - OMA-URI/custom policies → path/value table
+ - Settings Catalog → flattened key/value entries
+ - Standard objects → labeled key/value view with metadata filtered
+- Version detail pages show both pretty-printed JSON and normalized settings.
+- Warnings surface malformed snapshots or @odata.type mismatches before restore.
+
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php
index 201529b..5a4ff0f 100644
--- a/app/Filament/Resources/PolicyResource.php
+++ b/app/Filament/Resources/PolicyResource.php
@@ -6,6 +6,7 @@
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Models\Policy;
use App\Models\Tenant;
+use App\Services\Intune\PolicyNormalizer;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists;
@@ -39,6 +40,20 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('external_id')->label('External ID'),
Infolists\Components\TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
Infolists\Components\TextEntry::make('created_at')->since(),
+ Infolists\Components\ViewEntry::make('settings')
+ ->label('Settings')
+ ->view('filament.infolists.entries.normalized-settings')
+ ->state(function (Policy $record) {
+ $snapshot = $record->versions()
+ ->orderByDesc('captured_at')
+ ->value('snapshot');
+
+ return app(PolicyNormalizer::class)->normalize(
+ is_array($snapshot) ? $snapshot : [],
+ $record->policy_type ?? '',
+ $record->platform
+ );
+ }),
]);
}
@@ -64,6 +79,11 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('platform')
->badge()
->sortable(),
+ Tables\Columns\TextColumn::make('versions_count')
+ ->label('Settings')
+ ->badge()
+ ->state(fn (Policy $record) => $record->versions_count > 0 ? 'Available' : 'Missing')
+ ->color(fn (Policy $record) => $record->versions_count > 0 ? 'success' : 'gray'),
Tables\Columns\TextColumn::make('external_id')
->label('External ID')
->copyable()
@@ -116,7 +136,8 @@ public static function getEloquentQuery(): Builder
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
- ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
+ ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
+ ->withCount('versions');
}
public static function getRelations(): array
diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php
index 8f5b4df..f277deb 100644
--- a/app/Filament/Resources/PolicyVersionResource.php
+++ b/app/Filament/Resources/PolicyVersionResource.php
@@ -5,6 +5,8 @@
use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Models\PolicyVersion;
use App\Services\Intune\AuditLogger;
+use App\Services\Intune\PolicyNormalizer;
+use App\Services\Intune\VersionDiff;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists;
@@ -33,10 +35,20 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('platform'),
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
- Infolists\Components\TextEntry::make('snapshot')
- ->label('Snapshot')
- ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
- ->copyable(),
+ Infolists\Components\ViewEntry::make('snapshot_pretty')
+ ->label('Raw JSON')
+ ->view('filament.infolists.entries.snapshot-json')
+ ->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
+ Infolists\Components\ViewEntry::make('normalized_settings')
+ ->label('Normalized settings')
+ ->view('filament.infolists.entries.normalized-settings')
+ ->state(function (PolicyVersion $record) {
+ return app(PolicyNormalizer::class)->normalize(
+ is_array($record->snapshot) ? $record->snapshot : [],
+ $record->policy_type ?? '',
+ $record->platform
+ );
+ }),
Infolists\Components\TextEntry::make('diff')
->label('Diff vs previous')
->state(function (PolicyVersion $record) {
@@ -46,11 +58,26 @@ public static function infolist(Schema $schema): Schema
return ['summary' => 'No previous version'];
}
- return app(\App\Services\Intune\VersionDiff::class)
+ return app(VersionDiff::class)
->compare($previous->snapshot ?? [], $record->snapshot ?? []);
})
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
->copyable(),
+ Infolists\Components\ViewEntry::make('normalized_diff')
+ ->label('Normalized diff')
+ ->view('filament.infolists.entries.normalized-diff')
+ ->state(function (PolicyVersion $record) {
+ $normalizer = app(PolicyNormalizer::class);
+ $diff = app(VersionDiff::class);
+
+ $previous = $record->previous();
+ $from = $previous
+ ? $normalizer->flattenForDiff($previous->snapshot ?? [], $previous->policy_type ?? '', $previous->platform)
+ : [];
+ $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform);
+
+ return $diff->compare($from, $to);
+ }),
]);
}
diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php
index a6a1351..e3ef949 100644
--- a/app/Filament/Resources/RestoreRunResource.php
+++ b/app/Filament/Resources/RestoreRunResource.php
@@ -178,10 +178,10 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('requested_by'),
Infolists\Components\TextEntry::make('started_at')->dateTime(),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
- Infolists\Components\TextEntry::make('preview')
+ Infolists\Components\ViewEntry::make('preview')
->label('Preview')
- ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
- ->copyable(),
+ ->view('filament.infolists.entries.restore-preview')
+ ->state(fn ($record) => $record->preview ?? []),
Infolists\Components\TextEntry::make('results')
->label('Results')
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php
index 9a669f6..567b830 100644
--- a/app/Filament/Resources/TenantResource.php
+++ b/app/Filament/Resources/TenantResource.php
@@ -3,8 +3,12 @@
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
+use App\Http\Controllers\RbacDelegatedAuthController;
use App\Models\Tenant;
+use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
+use App\Services\Intune\RbacHealthService;
+use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use BackedEnum;
@@ -14,9 +18,15 @@
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
+use Filament\Schemas\Components\Utilities\Get;
+use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Throwable;
use UnitEnum;
class TenantResource extends Resource
@@ -156,10 +166,12 @@ public static function table(Table $table): Table
Tenant $record,
TenantConfigService $configService,
TenantPermissionService $permissionService,
+ RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
) {
- static::verifyTenant($record, $configService, $permissionService, $auditLogger);
+ static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
+ static::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
@@ -241,6 +253,19 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
+ Infolists\Components\TextEntry::make('rbac_status')->label('RBAC status')->badge(),
+ Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
+ Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
+ Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
+ Infolists\Components\TextEntry::make('rbac_role_definition_id')->label('Role definition ID')->copyable(),
+ Infolists\Components\TextEntry::make('rbac_scope_mode')->label('RBAC scope'),
+ Infolists\Components\TextEntry::make('rbac_scope_id')->label('Scope ID'),
+ Infolists\Components\TextEntry::make('rbac_group_id')->label('RBAC group ID')->copyable(),
+ Infolists\Components\TextEntry::make('rbac_role_assignment_id')->label('Role assignment ID')->copyable(),
+ Infolists\Components\ViewEntry::make('rbac_summary')
+ ->label('Last RBAC Setup')
+ ->view('filament.infolists.entries.rbac-summary')
+ ->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
@@ -272,23 +297,205 @@ public static function getPages(): array
];
}
+ public static function rbacAction(): Actions\Action
+ {
+ return Actions\Action::make('setup_rbac')
+ ->label('Setup Intune RBAC')
+ ->icon('heroicon-o-shield-check')
+ ->color('primary')
+ ->form([
+ Forms\Components\Select::make('role_definition_id')
+ ->label('RBAC role')
+ ->required()
+ ->searchable()
+ ->optionsLimit(20)
+ ->searchDebounce(400)
+ ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search))
+ ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel(
+ static::resolveRoleName($record, $value),
+ $value ?? ''
+ ))
+ ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
+ ->helperText(fn (?Tenant $record) => static::roleSearchHelper($record))
+ ->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record))
+ ->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.')
+ ->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).')
+ ->loadingMessage('Loading roles...')
+ ->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) {
+ $set('role_display_name', static::resolveRoleName($record, $state));
+ }),
+ Forms\Components\Hidden::make('role_display_name')
+ ->dehydrated(),
+ Forms\Components\Select::make('scope')
+ ->label('Scope')
+ ->required()
+ ->options([
+ 'all_devices' => 'All devices (global)',
+ 'scope_group' => 'Scope group (enter ID)',
+ ])
+ ->default('all_devices')
+ ->live(),
+ Forms\Components\Select::make('scope_group_id')
+ ->label('Scope group')
+ ->searchable()
+ ->searchPrompt('Type at least 2 characters')
+ ->optionsLimit(20)
+ ->searchDebounce(400)
+ ->placeholder('Search security groups')
+ ->visible(fn (Get $get) => $get('scope') === 'scope_group')
+ ->required(fn (Get $get) => $get('scope') === 'scope_group')
+ ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
+ ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
+ ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
+ ->hint(fn (?Tenant $record) => static::groupSearchHelper($record))
+ ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
+ ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
+ ->noSearchResultsMessage('No security groups found')
+ ->loadingMessage('Searching groups...'),
+ Forms\Components\Select::make('group_mode')
+ ->label('Group mode')
+ ->required()
+ ->options([
+ 'create' => 'Create new security group',
+ 'existing' => 'Use existing security group',
+ ])
+ ->default('create')
+ ->live(),
+ Forms\Components\TextInput::make('group_name')
+ ->label('Group name')
+ ->default('TenantPilot-Intune-RBAC')
+ ->visible(fn (callable $get) => $get('group_mode') === 'create'),
+ Forms\Components\Select::make('existing_group_id')
+ ->label('Security group')
+ ->searchable()
+ ->searchPrompt('Type at least 2 characters')
+ ->optionsLimit(20)
+ ->searchDebounce(400)
+ ->placeholder('Search security groups')
+ ->visible(fn (Get $get) => $get('group_mode') === 'existing')
+ ->required(fn (Get $get) => $get('group_mode') === 'existing')
+ ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
+ ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
+ ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
+ ->hint(fn (?Tenant $record) => static::groupSearchHelper($record))
+ ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
+ ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
+ ->noSearchResultsMessage('No security groups found')
+ ->loadingMessage('Searching groups...'),
+ ])
+ ->visible(fn (Tenant $record) => $record->isActive())
+ ->requiresConfirmation()
+ ->action(function (
+ array $data,
+ Tenant $record,
+ RbacOnboardingService $service,
+ AuditLogger $auditLogger
+ ) {
+ $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
+ $token = Cache::get($cacheKey);
+
+ if (! $token) {
+ Notification::make()
+ ->title('Login to grant RBAC')
+ ->body('Delegated login required to continue.')
+ ->actions([
+ Actions\Action::make('open_rbac_login')
+ ->label('Open RBAC login')
+ ->url(route('admin.rbac.start', [
+ 'tenant' => $record->graphTenantId(),
+ 'return' => route('filament.admin.resources.tenants.view', $record),
+ ])),
+ ])
+ ->warning()
+ ->persistent()
+ ->send();
+
+ return;
+ }
+
+ $actor = auth()->user();
+
+ $result = $service->run($record, $data, $actor, $token);
+
+ Cache::forget($cacheKey);
+
+ if ($result['status'] === 'success') {
+ Notification::make()
+ ->title('RBAC setup completed')
+ ->body(sprintf(
+ 'Role: %s | Scope: %s | Group: %s | Assignment: %s',
+ $data['role_display_name'] ?? $data['role_definition_id'] ?? 'n/a',
+ $data['scope'] ?? 'n/a',
+ $result['group_id'] ?? 'n/a',
+ $result['role_assignment_id'] ?? 'n/a'
+ ))
+ ->success()
+ ->send();
+
+ if (($data['scope'] ?? null) === 'scope_group') {
+ Notification::make()
+ ->title('Scope-limited selection')
+ ->body('RBAC scope is limited to a scope group; inventory/restore may be partial.')
+ ->warning()
+ ->send();
+ }
+
+ if (config('tenantpilot.features.conditional_access', false) === false) {
+ Notification::make()
+ ->title('CA canary disabled')
+ ->body('Conditional Access canary is disabled by feature flag.')
+ ->warning()
+ ->send();
+ }
+
+ return;
+ }
+
+ $auditLogger->log(
+ tenant: $record,
+ action: 'rbac.setup.failed',
+ resourceType: 'tenant',
+ resourceId: (string) $record->id,
+ status: 'error',
+ context: ['metadata' => ['error' => $result['message'] ?? 'unknown']],
+ );
+
+ Notification::make()
+ ->title('RBAC setup failed')
+ ->body($result['message'] ?? 'Unknown error')
+ ->danger()
+ ->send();
+ });
+ }
+
public static function adminConsentUrl(Tenant $tenant): ?string
{
$tenantId = $tenant->graphTenantId();
$clientId = $tenant->app_client_id;
$redirectUri = route('admin.consent.callback');
- $scope = config('graph.scope') ?: 'https://graph.microsoft.com/.default';
$state = sprintf('tenantpilot|%s', $tenant->id);
if (! $tenantId || ! $clientId || ! $redirectUri) {
return null;
}
+ // Build explicit scope list from required permissions
+ $requiredPermissions = config('intune_permissions.permissions', []);
+ $scopes = collect($requiredPermissions)
+ ->pluck('key')
+ ->map(fn (string $permission) => "https://graph.microsoft.com/{$permission}")
+ ->join(' ');
+
+ // Fallback to .default if no permissions configured
+ if (empty($scopes)) {
+ $scopes = 'https://graph.microsoft.com/.default';
+ }
+
$query = http_build_query([
'client_id' => $clientId,
'state' => $state,
'redirect_uri' => $redirectUri,
- 'scope' => $scope,
+ 'scope' => $scopes,
]);
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
@@ -313,25 +520,353 @@ public static function entraUrl(Tenant $tenant): ?string
return null;
}
+ private static function delegatedToken(?Tenant $tenant): ?string
+ {
+ if (! $tenant) {
+ return null;
+ }
+
+ $userKey = RbacDelegatedAuthController::cacheKey($tenant, auth()->id(), null);
+ $sessionKey = RbacDelegatedAuthController::cacheKey($tenant, auth()->id(), session()->getId());
+
+ return Cache::get($userKey) ?? Cache::get($sessionKey);
+ }
+
+ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action
+ {
+ if (! $tenant) {
+ return null;
+ }
+
+ return Actions\Action::make('login_to_load_roles')
+ ->label('Login to load roles')
+ ->url(route('admin.rbac.start', [
+ 'tenant' => $tenant->graphTenantId(),
+ 'return' => route('filament.admin.resources.tenants.view', $tenant),
+ ]));
+ }
+
+ public static function roleSearchHelper(?Tenant $tenant): ?string
+ {
+ return static::delegatedToken($tenant) ? null : 'Login to load roles';
+ }
+
+ /**
+ * @return array
+ */
+ public static function roleSearchOptions(?Tenant $tenant, string $search): array
+ {
+ return static::searchRoleDefinitions($tenant, $search);
+ }
+
+ /**
+ * @return array
+ */
+ private static function searchRoleDefinitions(?Tenant $tenant, string $search): array
+ {
+ if (! $tenant) {
+ return [];
+ }
+
+ $token = static::delegatedToken($tenant);
+
+ if (! $token) {
+ return [];
+ }
+
+ if (Str::contains(Str::lower($search), 'intune administrator')) {
+ Notification::make()
+ ->title('Intune Administrator is a directory role')
+ ->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.')
+ ->warning()
+ ->persistent()
+ ->send();
+ }
+
+ $filter = mb_strlen($search) >= 2
+ ? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search))
+ : null;
+
+ $query = [
+ '$select' => 'id,displayName,isBuiltIn',
+ '$top' => 20,
+ ];
+
+ if ($filter) {
+ $query['$filter'] = $filter;
+ }
+
+ try {
+ $response = app(GraphClientInterface::class)->request(
+ 'GET',
+ 'deviceManagement/roleDefinitions',
+ [
+ 'query' => $query,
+ ] + $tenant->graphOptions() + [
+ 'access_token' => $token,
+ ]
+ );
+ } catch (Throwable) {
+ static::notifyRoleLookupFailure();
+
+ return [];
+ }
+
+ if ($response->failed()) {
+ static::notifyRoleLookupFailure();
+
+ return [];
+ }
+
+ $roles = collect($response->data['value'] ?? [])
+ ->filter(fn (array $role) => filled($role['id'] ?? null))
+ ->mapWithKeys(fn (array $role) => [
+ $role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']),
+ ])
+ ->all();
+
+ if (empty($roles)) {
+ static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []);
+ }
+
+ return $roles;
+ }
+
+ private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string
+ {
+ if (! $tenant || blank($roleId)) {
+ return $roleId;
+ }
+
+ $token = static::delegatedToken($tenant);
+
+ if (! $token) {
+ return $roleId;
+ }
+
+ try {
+ $response = app(GraphClientInterface::class)->request(
+ 'GET',
+ "deviceManagement/roleDefinitions/{$roleId}",
+ [
+ 'query' => [
+ '$select' => 'id,displayName',
+ ],
+ ] + $tenant->graphOptions() + [
+ 'access_token' => $token,
+ ]
+ );
+ } catch (Throwable) {
+ static::notifyRoleLookupFailure();
+
+ return $roleId;
+ }
+
+ if ($response->failed()) {
+ static::notifyRoleLookupFailure();
+
+ return $roleId;
+ }
+
+ $displayName = $response->data['displayName'] ?? null;
+ $id = $response->data['id'] ?? $roleId;
+
+ return $displayName ?: $id;
+ }
+
+ private static function formatRoleLabel(?string $displayName, string $id): string
+ {
+ $suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
+
+ return trim(($displayName ?: 'RBAC role').$suffix);
+ }
+
+ private static function notifyRoleLookupFailure(): void
+ {
+ Notification::make()
+ ->title('Role lookup failed')
+ ->body('Delegated session may have expired. Login again to load Intune RBAC roles.')
+ ->danger()
+ ->send();
+ }
+
+ private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void
+ {
+ $names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all();
+
+ Log::warning('rbac.role_definitions.empty', [
+ 'tenant_id' => $tenant->id,
+ 'count' => count($roles),
+ 'sample' => $names,
+ ]);
+
+ try {
+ app(AuditLogger::class)->log(
+ tenant: $tenant,
+ action: 'rbac.roles.empty',
+ resourceType: 'tenant',
+ resourceId: (string) $tenant->id,
+ status: 'warning',
+ context: ['metadata' => ['count' => count($roles), 'sample' => $names]],
+ );
+ } catch (Throwable) {
+ Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]);
+ }
+ }
+
+ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action
+ {
+ if (! $tenant) {
+ return null;
+ }
+
+ return Actions\Action::make('login_to_search_groups')
+ ->label('Login to search groups')
+ ->url(route('admin.rbac.start', [
+ 'tenant' => $tenant->graphTenantId(),
+ 'return' => route('filament.admin.resources.tenants.view', $tenant),
+ ]));
+ }
+
+ public static function groupSearchHelper(?Tenant $tenant): ?string
+ {
+ return static::delegatedToken($tenant) ? null : 'Login to search groups';
+ }
+
+ /**
+ * @return array
+ */
+ public static function groupSearchOptions(?Tenant $tenant, string $search): array
+ {
+ return static::searchSecurityGroups($tenant, $search);
+ }
+
+ /**
+ * @return array
+ */
+ private static function searchSecurityGroups(?Tenant $tenant, string $search): array
+ {
+ if (! $tenant || mb_strlen($search) < 2) {
+ return [];
+ }
+
+ $token = static::delegatedToken($tenant);
+
+ if (! $token) {
+ return [];
+ }
+
+ try {
+ $response = app(GraphClientInterface::class)->request(
+ 'GET',
+ 'groups',
+ [
+ 'query' => [
+ '$filter' => sprintf(
+ "securityEnabled eq true and startswith(displayName,'%s')",
+ static::escapeOdataValue($search)
+ ),
+ '$select' => 'id,displayName',
+ '$top' => 20,
+ ],
+ ] + $tenant->graphOptions() + [
+ 'access_token' => $token,
+ ]
+ );
+ } catch (Throwable) {
+ static::notifyGroupLookupFailure();
+
+ return [];
+ }
+
+ if ($response->failed()) {
+ static::notifyGroupLookupFailure();
+
+ return [];
+ }
+
+ return collect($response->data['value'] ?? [])
+ ->filter(fn (array $group) => filled($group['id'] ?? null))
+ ->mapWithKeys(fn (array $group) => [
+ $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']),
+ ])
+ ->all();
+ }
+
+ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string
+ {
+ if (! $tenant || blank($groupId)) {
+ return $groupId;
+ }
+
+ $token = static::delegatedToken($tenant);
+
+ if (! $token) {
+ return $groupId;
+ }
+
+ try {
+ $response = app(GraphClientInterface::class)->request(
+ 'GET',
+ "groups/{$groupId}",
+ [
+ 'query' => [
+ '$select' => 'id,displayName',
+ ],
+ ] + $tenant->graphOptions() + [
+ 'access_token' => $token,
+ ]
+ );
+ } catch (Throwable) {
+ static::notifyGroupLookupFailure();
+
+ return $groupId;
+ }
+
+ if ($response->failed()) {
+ static::notifyGroupLookupFailure();
+
+ return $groupId;
+ }
+
+ $displayName = $response->data['displayName'] ?? null;
+ $id = $response->data['id'] ?? $groupId;
+
+ return static::formatGroupLabel($displayName, $id);
+ }
+
+ private static function formatGroupLabel(?string $displayName, string $id): string
+ {
+ $suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
+
+ return trim(($displayName ?: 'Security group').$suffix);
+ }
+
+ private static function escapeOdataValue(string $value): string
+ {
+ return str_replace("'", "''", $value);
+ }
+
+ private static function notifyGroupLookupFailure(): void
+ {
+ Notification::make()
+ ->title('Group lookup failed')
+ ->body('Delegated session may have expired. Login again to search security groups.')
+ ->danger()
+ ->send();
+ }
+
public static function verifyTenant(
Tenant $tenant,
TenantConfigService $configService,
TenantPermissionService $permissionService,
+ RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
): void {
$configResult = $configService->testConnectivity($tenant);
-
- $permissionStatuses = [];
- foreach ($permissionService->getRequiredPermissions() as $permission) {
- $permissionStatuses[$permission['key']] = [
- 'status' => $configResult['success'] ? 'ok' : 'error',
- 'details' => $configResult['success']
- ? null
- : ['message' => $configResult['error_message']],
- ];
- }
-
- $permissions = $permissionService->compare($tenant, $permissionStatuses);
+ // Fetch actual permissions from Graph API with liveCheck=true
+ $permissions = $permissionService->compare($tenant, null, true, true);
+ $rbac = $rbacHealthService->check($tenant);
$appStatus = $configResult['success']
? 'ok'
@@ -381,6 +916,20 @@ public static function verifyTenant(
resourceId: (string) $tenant->id,
);
+ $auditLogger->log(
+ tenant: $tenant,
+ action: 'tenant.rbac.checked',
+ context: [
+ 'metadata' => [
+ 'status' => $rbac['status'],
+ 'reason' => $rbac['reason'] ?? null,
+ ],
+ ],
+ status: $rbac['status'] === 'ok' ? 'success' : 'error',
+ resourceType: 'tenant',
+ resourceId: (string) $tenant->id,
+ );
+
$notification = Notification::make()
->title($configResult['success'] ? 'Configuration verified' : 'Verification failed')
->body($configResult['success']
diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
index 31b8402..cad7dac 100644
--- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
+++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
@@ -5,6 +5,7 @@
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
+use App\Services\Intune\RbacHealthService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use Filament\Actions;
@@ -39,10 +40,12 @@ protected function getHeaderActions(): array
Tenant $record,
TenantConfigService $configService,
TenantPermissionService $permissionService,
+ RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
) {
- TenantResource::verifyTenant($record, $configService, $permissionService, $auditLogger);
+ TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
+ TenantResource::rbacAction(),
];
}
}
diff --git a/app/Http/Controllers/RbacDelegatedAuthController.php b/app/Http/Controllers/RbacDelegatedAuthController.php
new file mode 100644
index 0000000..c79e24b
--- /dev/null
+++ b/app/Http/Controllers/RbacDelegatedAuthController.php
@@ -0,0 +1,148 @@
+string('tenant')->toString();
+ $tenant = $tenantIdentifier
+ ? Tenant::query()->forTenant($tenantIdentifier)->firstOrFail()
+ : Tenant::current();
+
+ $targetTenant = $tenantIdentifier ?: $tenant->graphTenantId();
+
+ $clientId = config('graph.client_id');
+ $redirectUri = route('admin.rbac.callback');
+
+ abort_if(empty($clientId) || empty($redirectUri), Response::HTTP_BAD_GATEWAY, 'Graph client not configured');
+
+ $state = Str::uuid()->toString();
+
+ $request->session()->put('rbac_state', $state);
+ $request->session()->put('rbac_tenant', $tenant->getKey());
+ $returnTo = $this->sanitizeReturnPath(
+ $request->string('return')->toString()
+ ?: route('filament.admin.resources.tenants.view', $tenant)
+ );
+ $request->session()->put('rbac_return', $returnTo);
+
+ $scopes = implode(' ', [
+ 'openid',
+ 'profile',
+ 'offline_access',
+ 'Directory.ReadWrite.All',
+ 'Group.ReadWrite.All',
+ 'DeviceManagementRBAC.ReadWrite.All',
+ 'DeviceManagementConfiguration.ReadWrite.All',
+ ]);
+
+ $url = "https://login.microsoftonline.com/{$targetTenant}/oauth2/v2.0/authorize?".http_build_query([
+ 'client_id' => $clientId,
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirectUri,
+ 'response_mode' => 'query',
+ 'scope' => $scopes,
+ 'state' => $state,
+ ]);
+
+ return redirect()->away($url);
+ }
+
+ public function callback(Request $request): RedirectResponse
+ {
+ $expectedState = $request->session()->pull('rbac_state');
+ $tenantId = $request->session()->pull('rbac_tenant');
+ $returnTo = $request->session()->pull('rbac_return');
+
+ abort_if(! $expectedState, Response::HTTP_FORBIDDEN, 'RBAC state missing');
+ abort_if($expectedState !== $request->string('state')->toString(), Response::HTTP_FORBIDDEN, 'Invalid RBAC state');
+ abort_if(! $tenantId, Response::HTTP_BAD_REQUEST, 'Tenant context missing');
+
+ /** @var Tenant $tenant */
+ $tenant = Tenant::query()->findOrFail($tenantId);
+
+ $code = $request->string('code')->toString();
+ abort_if(empty($code), Response::HTTP_BAD_REQUEST, 'Authorization code missing');
+
+ $tokens = $this->exchangeAuthorizationCode($code);
+
+ if (empty($tokens['access_token'])) {
+ abort(Response::HTTP_BAD_GATEWAY, 'Failed to exchange code for token');
+ }
+
+ $accessToken = $tokens['access_token'];
+ $ttl = CarbonImmutable::now()->addMinutes(5);
+
+ // Store token keyed by user id (preferred) and session id (fallback) to survive SPA refreshes.
+ if (auth()->check()) {
+ Cache::put($this->cacheKey($tenant, auth()->id(), null), $accessToken, $ttl);
+ }
+
+ Cache::put($this->cacheKey($tenant, auth()->id(), $request->session()->getId()), $accessToken, $ttl);
+
+ $destination = $this->sanitizeReturnPath($returnTo) ?: route('filament.admin.resources.tenants.view', $tenant);
+
+ return redirect()->to($destination);
+ }
+
+ /**
+ * @return array{access_token:?string,refresh_token:?string,expires_in:?int}
+ */
+ private function exchangeAuthorizationCode(string $code): array
+ {
+ $response = Http::asForm()->post(sprintf(
+ 'https://login.microsoftonline.com/%s/oauth2/v2.0/token',
+ config('graph.tenant_id', 'common')
+ ), [
+ 'client_id' => config('graph.client_id'),
+ 'client_secret' => config('graph.client_secret'),
+ 'code' => $code,
+ 'grant_type' => 'authorization_code',
+ 'redirect_uri' => route('admin.rbac.callback'),
+ ]);
+
+ if ($response->failed()) {
+ return [];
+ }
+
+ $json = $response->json() ?: [];
+
+ return [
+ 'access_token' => $json['access_token'] ?? null,
+ 'refresh_token' => $json['refresh_token'] ?? null,
+ 'expires_in' => $json['expires_in'] ?? null,
+ ];
+ }
+
+ public static function cacheKey(Tenant $tenant, ?int $userId = null, ?string $sessionId = null): string
+ {
+ $suffix = $userId ? "user_{$userId}" : 'session_'.($sessionId ?: 'anon');
+
+ return sprintf('rbac_delegated_token_%s_%s', $tenant->getKey(), $suffix);
+ }
+
+ private function sanitizeReturnPath(?string $path): ?string
+ {
+ if (empty($path)) {
+ return null;
+ }
+
+ if (str_starts_with($path, 'http')) {
+ $parsed = parse_url($path);
+ $path = $parsed['path'] ?? '/';
+ }
+
+ return str_starts_with($path, '/') ? $path : '/';
+ }
+}
diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php
index 6574131..dde4183 100644
--- a/app/Models/BackupItem.php
+++ b/app/Models/BackupItem.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Support\Concerns\InteractsWithODataTypes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,6 +11,7 @@
class BackupItem extends Model
{
use HasFactory;
+ use InteractsWithODataTypes;
use SoftDeletes;
protected $guarded = [];
diff --git a/app/Models/Policy.php b/app/Models/Policy.php
index a9667cc..61498d9 100644
--- a/app/Models/Policy.php
+++ b/app/Models/Policy.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Support\Concerns\InteractsWithODataTypes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,6 +11,7 @@
class Policy extends Model
{
use HasFactory;
+ use InteractsWithODataTypes;
protected $guarded = [];
diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php
index b85782b..bc0a134 100644
--- a/app/Models/Tenant.php
+++ b/app/Models/Tenant.php
@@ -22,8 +22,40 @@ class Tenant extends Model
'metadata' => 'array',
'app_client_secret' => 'encrypted',
'is_current' => 'boolean',
+ 'rbac_last_checked_at' => 'datetime',
+ 'rbac_last_setup_at' => 'datetime',
+ 'rbac_canary_results' => 'array',
+ 'rbac_last_warnings' => 'array',
];
+ public function getRbacCanaryResultsAttribute($value): array
+ {
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ return $value ?? [];
+ }
+
+ public function getRbacLastWarningsAttribute($value): array
+ {
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ $warnings = $value ?? [];
+
+ if ($this->rbac_scope_mode === 'scope_group' || filled($this->rbac_scope_id)) {
+ $warnings[] = 'scope_limited';
+ }
+
+ return $warnings;
+ }
+
protected static function booted(): void
{
static::creating(function (Tenant $tenant) {
diff --git a/app/Services/Graph/GraphClientInterface.php b/app/Services/Graph/GraphClientInterface.php
index f02ff71..1ba3111 100644
--- a/app/Services/Graph/GraphClientInterface.php
+++ b/app/Services/Graph/GraphClientInterface.php
@@ -26,4 +26,16 @@ public function getOrganization(array $options = []): GraphResponse;
* Apply or restore a policy payload.
*/
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse;
+
+ /**
+ * Get granted OAuth2 permissions for the service principal.
+ */
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse;
+
+ /**
+ * Execute an arbitrary Graph request (used for specialized operations like RBAC setup).
+ *
+ * Supported options: `query`, `json`, `tenant`, `client_id`, `client_secret`, `scope`, `token_url`, `access_token`.
+ */
+ public function request(string $method, string $path, array $options = []): GraphResponse;
}
diff --git a/app/Services/Graph/GraphLogger.php b/app/Services/Graph/GraphLogger.php
index 8c438fc..e1dce26 100644
--- a/app/Services/Graph/GraphLogger.php
+++ b/app/Services/Graph/GraphLogger.php
@@ -3,21 +3,87 @@
namespace App\Services\Graph;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
class GraphLogger
{
public function logRequest(string $action, array $context = []): void
{
- Log::info('graph.request', ['action' => $action] + $context);
+ $payload = [
+ 'action' => $action,
+ 'method' => $context['method'] ?? null,
+ 'endpoint' => $context['endpoint'] ?? null,
+ 'full_path' => $context['full_path'] ?? null,
+ 'query' => $context['query'] ?? null,
+ 'tenant' => $context['tenant'] ?? null,
+ 'client_request_id' => $context['client_request_id'] ?? null,
+ ];
+
+ Log::info('graph.request', $this->filter($payload));
}
public function logResponse(string $action, GraphResponse $response, array $context = []): void
{
- Log::info('graph.response', [
+ $payload = [
'action' => $action,
'status' => $response->status,
'success' => $response->success,
'warnings' => $response->warnings,
- ] + $context);
+ 'method' => $context['method'] ?? $response->meta['method'] ?? null,
+ 'endpoint' => $context['endpoint'] ?? $response->meta['path'] ?? null,
+ 'full_path' => $context['full_path'] ?? $response->meta['full_path'] ?? null,
+ 'tenant' => $context['tenant'] ?? $response->meta['tenant'] ?? null,
+ 'request_id' => $response->meta['request_id'] ?? null,
+ 'client_request_id' => $response->meta['client_request_id'] ?? null,
+ ];
+
+ if ($response->failed()) {
+ $payload['error_code'] = $response->meta['error_code'] ?? $this->firstErrorField($response->errors, 'code');
+ $payload['error_message'] = $response->meta['error_message'] ?? $this->firstErrorMessage($response->errors, $response->data);
+ $payload['response_excerpt'] = $response->meta['body_excerpt'] ?? null;
+ }
+
+ Log::info('graph.response', $this->filter($payload));
+ }
+
+ private function firstErrorField(array $errors, string $key): ?string
+ {
+ foreach ($errors as $error) {
+ if (is_array($error) && is_string($error[$key] ?? null)) {
+ return $error[$key];
+ }
+ }
+
+ return null;
+ }
+
+ private function firstErrorMessage(array $errors, array $data): ?string
+ {
+ $message = $data['error']['message'] ?? null;
+
+ if (is_string($message) && $message !== '') {
+ return $message;
+ }
+
+ foreach ($errors as $error) {
+ if (is_array($error) && is_string($error['message'] ?? null)) {
+ return $error['message'];
+ }
+
+ if (is_array($error) && is_string($error['error']['message'] ?? null)) {
+ return $error['error']['message'];
+ }
+
+ if (is_string($error) && $error !== '') {
+ return Str::limit($error, 500);
+ }
+ }
+
+ return null;
+ }
+
+ private function filter(array $payload): array
+ {
+ return array_filter($payload, static fn ($value) => $value !== null && $value !== []);
}
}
diff --git a/app/Services/Graph/GraphResponse.php b/app/Services/Graph/GraphResponse.php
index c44c3d2..4751620 100644
--- a/app/Services/Graph/GraphResponse.php
+++ b/app/Services/Graph/GraphResponse.php
@@ -10,6 +10,7 @@ public function __construct(
public readonly ?int $status = null,
public readonly array $errors = [],
public readonly array $warnings = [],
+ public readonly array $meta = [],
) {}
public function successful(): bool
diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php
index 68da5d5..3116566 100644
--- a/app/Services/Graph/MicrosoftGraphClient.php
+++ b/app/Services/Graph/MicrosoftGraphClient.php
@@ -3,9 +3,11 @@
namespace App\Services\Graph;
use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Str;
use Throwable;
class MicrosoftGraphClient implements GraphClientInterface
@@ -54,19 +56,33 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
], fn ($value) => $value !== null && $value !== '');
$context = $this->resolveContext($options);
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+ $fullPath = $this->buildFullPath($endpoint, $query);
$this->logger->logRequest('list_policies', [
'endpoint' => $endpoint,
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
'policy_type' => $policyType,
'tenant' => $context['tenant'],
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
]);
- $response = $this->send('GET', $endpoint, ['query' => $query], $context);
+ $response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'list_policies',
response: $response,
- transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : [])
+ transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $endpoint,
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
+ ]
);
}
@@ -78,20 +94,34 @@ public function getPolicy(string $policyType, string $policyId, array $options =
], fn ($value) => $value !== null && $value !== '');
$context = $this->resolveContext($options);
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+ $fullPath = $this->buildFullPath($endpoint, $query);
$this->logger->logRequest('get_policy', [
'endpoint' => $endpoint,
'policy_type' => $policyType,
'policy_id' => $policyId,
'tenant' => $context['tenant'],
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
]);
- $response = $this->send('GET', $endpoint, ['query' => $query], $context);
+ $response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'get_policy',
response: $response,
- transform: fn (array $json) => ['payload' => $json]
+ transform: fn (array $json) => ['payload' => $json],
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $endpoint,
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
+ ]
);
}
@@ -99,18 +129,30 @@ public function getOrganization(array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
$endpoint = 'organization';
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+ $fullPath = $this->buildFullPath($endpoint);
$this->logger->logRequest('get_organization', [
'endpoint' => $endpoint,
'tenant' => $context['tenant'],
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
+ 'client_request_id' => $clientRequestId,
]);
- $response = $this->send('GET', $endpoint, [], $context);
+ $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'get_organization',
response: $response,
- transform: fn (array $json) => $json['value'][0] ?? $json
+ transform: fn (array $json) => $json['value'][0] ?? $json,
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $endpoint,
+ 'full_path' => $fullPath,
+ 'method' => 'GET',
+ 'client_request_id' => $clientRequestId,
+ ]
);
}
@@ -120,33 +162,217 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
$method = strtoupper($options['method'] ?? 'PATCH');
$context = $this->resolveContext($options);
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+ $fullPath = $this->buildFullPath($endpoint);
$this->logger->logRequest('apply_policy', [
'endpoint' => $endpoint,
'policy_type' => $policyType,
'policy_id' => $policyId,
'tenant' => $context['tenant'],
+ 'method' => $method,
+ 'full_path' => $fullPath,
+ 'client_request_id' => $clientRequestId,
]);
- $response = $this->send($method, $endpoint, ['json' => $payload], $context);
+ $response = $this->send($method, $endpoint, ['json' => $payload, 'client_request_id' => $clientRequestId], $context);
return $this->toGraphResponse(
action: 'apply_policy',
response: $response,
- transform: fn (array $json) => $json
+ transform: fn (array $json) => $json,
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $endpoint,
+ 'full_path' => $fullPath,
+ 'method' => $method,
+ 'client_request_id' => $clientRequestId,
+ ]
+ );
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ $context = $this->resolveContext($options);
+ $clientId = $context['client_id'];
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+
+ // First, get the service principal object by clientId (appId)
+ $endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
+
+ $this->logger->logRequest('get_service_principal', [
+ 'endpoint' => $endpoint,
+ 'client_id' => $clientId,
+ 'tenant' => $context['tenant'],
+ 'method' => 'GET',
+ 'full_path' => $endpoint,
+ 'client_request_id' => $clientRequestId,
+ ]);
+
+ $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
+
+ if ($response->failed()) {
+ return $this->toGraphResponse(
+ action: 'get_service_principal',
+ response: $response,
+ transform: fn (array $json) => [],
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $endpoint,
+ 'full_path' => $endpoint,
+ 'method' => 'GET',
+ 'client_request_id' => $clientRequestId,
+ ]
+ );
+ }
+
+ $servicePrincipals = $response->json('value', []);
+ if (empty($servicePrincipals)) {
+ return new GraphResponse(
+ success: false,
+ data: [],
+ status: 404,
+ errors: [['message' => 'Service principal not found']],
+ );
+ }
+
+ $servicePrincipalId = $servicePrincipals[0]['id'] ?? null;
+ if (! $servicePrincipalId) {
+ return new GraphResponse(
+ success: false,
+ data: [],
+ status: 500,
+ errors: [['message' => 'Service principal ID missing']],
+ );
+ }
+
+ // Now get the app role assignments (application permissions)
+ $assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
+
+ $this->logger->logRequest('get_app_role_assignments', [
+ 'endpoint' => $assignmentsEndpoint,
+ 'service_principal_id' => $servicePrincipalId,
+ 'tenant' => $context['tenant'],
+ 'method' => 'GET',
+ 'full_path' => $assignmentsEndpoint,
+ 'client_request_id' => $clientRequestId,
+ ]);
+
+ $assignmentsResponse = $this->send('GET', $assignmentsEndpoint, ['client_request_id' => $clientRequestId], $context);
+
+ return $this->toGraphResponse(
+ action: 'get_service_principal_permissions',
+ response: $assignmentsResponse,
+ transform: function (array $json) use ($context) {
+ $assignments = $json['value'] ?? [];
+ $permissions = [];
+
+ // Get Microsoft Graph service principal to map role IDs to permission names
+ $graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
+ $graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
+ $graphSps = $graphSpResponse->json('value', []);
+ $appRoles = $graphSps[0]['appRoles'] ?? [];
+
+ // Map role IDs to permission names
+ $roleMap = [];
+ foreach ($appRoles as $role) {
+ $roleMap[$role['id']] = $role['value'];
+ }
+
+ foreach ($assignments as $assignment) {
+ $roleId = $assignment['appRoleId'] ?? null;
+ if ($roleId && isset($roleMap[$roleId])) {
+ $permissions[] = $roleMap[$roleId];
+ }
+ }
+
+ return ['permissions' => $permissions];
+ },
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $assignmentsEndpoint,
+ 'full_path' => $assignmentsEndpoint,
+ 'method' => 'GET',
+ 'client_request_id' => $clientRequestId,
+ ]
+ );
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ $context = $this->resolveContext($options);
+ $method = strtoupper($method);
+ $query = $options['query'] ?? [];
+ $fullPath = $this->buildFullPath($path, $query);
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+ $action = strtolower($method).' '.$fullPath;
+
+ $this->logger->logRequest($action, [
+ 'endpoint' => $path,
+ 'full_path' => $fullPath,
+ 'method' => $method,
+ 'tenant' => $context['tenant'],
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
+ ]);
+
+ $options['client_request_id'] = $clientRequestId;
+
+ try {
+ $response = $this->send($method, $path, $options, $context);
+ } catch (RequestException|GraphException $exception) {
+ $graphResponse = $this->graphResponseFromException($exception, [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $path,
+ 'full_path' => $fullPath,
+ 'method' => $method,
+ 'client_request_id' => $clientRequestId,
+ 'query' => $query ?: null,
+ ]);
+
+ $this->logger->logResponse($action, $graphResponse, [
+ 'tenant' => $context['tenant'] ?? null,
+ 'endpoint' => $path,
+ 'full_path' => $fullPath,
+ 'method' => $method,
+ ]);
+
+ return $graphResponse;
+ }
+
+ return $this->toGraphResponse(
+ action: $action,
+ response: $response,
+ transform: fn (array $json) => $json,
+ meta: [
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $path,
+ 'full_path' => $fullPath,
+ 'method' => $method,
+ 'query' => $query ?: null,
+ 'client_request_id' => $clientRequestId,
+ ]
);
}
private function send(string $method, string $path, array $options = [], array $context = []): Response
{
$context = $context ?: $this->resolveContext([]);
- $token = $this->getAccessToken($context);
+ $token = $options['access_token'] ?? $context['access_token'] ?? null;
+ $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
+
+ if (! $token) {
+ $token = $this->getAccessToken($context);
+ }
$pending = Http::baseUrl($this->baseUrl)
->acceptJson()
->timeout($this->timeout)
->retry($this->retryTimes, $this->retrySleepMs)
- ->withToken($token);
+ ->withToken($token)
+ ->withHeaders([
+ 'client-request-id' => $clientRequestId,
+ ]);
if (! empty($options['query'])) {
$pending = $pending->withQueryParameters($options['query']);
@@ -164,6 +390,15 @@ private function send(string $method, string $path, array $options = [], array $
null,
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
);
+ } catch (RequestException $exception) {
+ if ($exception->response) {
+ $response = $exception->response;
+ } else {
+ throw GraphErrorMapper::fromThrowable(
+ $exception,
+ ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
+ );
+ }
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable(
$throwable,
@@ -171,35 +406,70 @@ private function send(string $method, string $path, array $options = [], array $
);
}
- $this->logger->logResponse($method.' '.$path, new GraphResponse(
- success: $response->successful(),
- data: [],
- status: $response->status(),
- errors: $response->json('error') ? [$response->json('error')] : [],
- ), ['tenant' => $context['tenant'] ?? null]);
-
return $response;
}
- private function toGraphResponse(string $action, Response $response, callable $transform): GraphResponse
+ private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = []): GraphResponse
{
- if ($response->failed()) {
- $error = $response->json('error') ?? $response->json() ?? $response->body();
+ $json = $response->json() ?? [];
+ $meta = $this->responseMeta($response, $meta);
- return new GraphResponse(
+ if ($response->failed()) {
+ $error = $response->json('error') ?? $json ?? $response->body();
+
+ $graphResponse = new GraphResponse(
success: false,
- data: [],
+ data: is_array($json) ? $json : [],
status: $response->status(),
errors: is_array($error) ? [$error] : [$error],
+ meta: $meta,
);
+
+ $this->logger->logResponse($action, $graphResponse, $meta);
+
+ return $graphResponse;
}
- $json = $response->json() ?? [];
-
- return new GraphResponse(
+ $graphResponse = new GraphResponse(
success: true,
data: $transform(is_array($json) ? $json : []),
status: $response->status(),
+ meta: $meta,
+ );
+
+ $this->logger->logResponse($action, $graphResponse, $meta);
+
+ return $graphResponse;
+ }
+
+ private function graphResponseFromException(RequestException|GraphException $exception, array $context = []): GraphResponse
+ {
+ if ($exception instanceof RequestException && $exception->response) {
+ $response = $exception->response;
+ $json = $response->json() ?? [];
+ $error = $response->json('error') ?? $json ?? $response->body();
+
+ return new GraphResponse(
+ success: false,
+ data: is_array($json) ? $json : [],
+ status: $response->status(),
+ errors: is_array($error) ? [$error] : [$error],
+ meta: $this->responseMeta($response, $context),
+ );
+ }
+
+ $contextualError = [];
+
+ if ($exception instanceof GraphException && ! empty($exception->context)) {
+ $contextualError = $exception->context + ['message' => $exception->getMessage()];
+ }
+
+ return new GraphResponse(
+ success: false,
+ data: [],
+ status: $exception instanceof GraphException ? $exception->status : null,
+ errors: [$contextualError ?: $exception->getMessage()],
+ meta: $context,
);
}
@@ -312,4 +582,41 @@ private function requestAccessToken(array $context): array
return [(string) $data['access_token'], $ttl];
}
+
+ private function buildFullPath(string $path, array $query = []): string
+ {
+ $path = ltrim($path, '/');
+
+ if (empty($query)) {
+ return $path;
+ }
+
+ return sprintf('%s?%s', $path, http_build_query($query));
+ }
+
+ /**
+ * @return array
+ */
+ private function responseMeta(Response $response, array $context = []): array
+ {
+ $requestId = $response->header('request-id') ?? $response->header('x-ms-request-id');
+ $clientRequestId = $response->header('client-request-id') ?? $context['client_request_id'] ?? null;
+ $bodyExcerpt = Str::limit((string) $response->body(), 2000);
+ $jsonError = $response->json('error');
+ $errorCode = is_array($jsonError) ? ($jsonError['code'] ?? null) : null;
+ $errorMessage = is_array($jsonError) ? ($jsonError['message'] ?? null) : (is_string($jsonError) ? $jsonError : null);
+
+ return array_filter([
+ 'tenant' => $context['tenant'] ?? null,
+ 'path' => $context['path'] ?? null,
+ 'full_path' => $context['full_path'] ?? null,
+ 'method' => $context['method'] ?? null,
+ 'query' => $context['query'] ?? null,
+ 'request_id' => $requestId,
+ 'client_request_id' => $clientRequestId,
+ 'body_excerpt' => $bodyExcerpt,
+ 'error_code' => $errorCode,
+ 'error_message' => $errorMessage,
+ ], static fn ($value) => $value !== null && $value !== '');
+ }
}
diff --git a/app/Services/Graph/NullGraphClient.php b/app/Services/Graph/NullGraphClient.php
index ad40ce5..9b795ce 100644
--- a/app/Services/Graph/NullGraphClient.php
+++ b/app/Services/Graph/NullGraphClient.php
@@ -46,4 +46,29 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
warnings: ['Graph client not configured; apply operation skipped']
);
}
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ // Return stub permissions from config
+ $grantedStub = config('intune_permissions.granted_stub', []);
+
+ return new GraphResponse(
+ success: true,
+ data: ['permissions' => $grantedStub],
+ warnings: ['Graph client not configured; using stub permissions from config']
+ );
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(
+ success: true,
+ data: [
+ 'method' => strtoupper($method),
+ 'path' => $path,
+ 'options' => $options,
+ ],
+ warnings: ['Graph client not configured; returning stub response'],
+ );
+ }
}
diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php
index e8a1821..7835774 100644
--- a/app/Services/Intune/BackupService.php
+++ b/app/Services/Intune/BackupService.php
@@ -21,6 +21,7 @@ public function __construct(
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
private readonly VersionService $versionService,
+ private readonly SnapshotValidator $snapshotValidator,
) {}
/**
@@ -225,6 +226,7 @@ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $po
$payload = $response->data['payload'] ?? $response->data;
$metadata = Arr::except($response->data, ['payload']);
+ $metadataWarnings = $metadata['warnings'] ?? [];
if ($response->failed()) {
$reason = $response->warnings[0] ?? 'Graph request failed';
@@ -245,7 +247,20 @@ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $po
'source' => 'stub',
'warning' => $reason,
];
- $metadata['warnings'] = $response->warnings ?? [$reason];
+ $metadataWarnings = $response->warnings ?? [$reason];
+ }
+
+ $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
+ $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
+
+ $odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform);
+
+ if ($odataWarning) {
+ $metadataWarnings[] = $odataWarning;
+ }
+
+ if (! empty($metadataWarnings)) {
+ $metadata['warnings'] = array_values(array_unique($metadataWarnings));
}
$backupItem = BackupItem::create([
diff --git a/app/Services/Intune/PolicyNormalizer.php b/app/Services/Intune/PolicyNormalizer.php
new file mode 100644
index 0000000..e678858
--- /dev/null
+++ b/app/Services/Intune/PolicyNormalizer.php
@@ -0,0 +1,200 @@
+>, warnings: array}
+ */
+ public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
+ {
+ $snapshot = $snapshot ?? [];
+ $resultWarnings = [];
+ $status = 'success';
+
+ $validation = $this->validator->validate($snapshot);
+ $resultWarnings = array_merge($resultWarnings, $validation['warnings']);
+
+ $odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform);
+
+ if ($odataWarning) {
+ $resultWarnings[] = $odataWarning;
+ }
+
+ if ($snapshot === []) {
+ return [
+ 'status' => 'warning',
+ 'settings' => [],
+ 'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))),
+ ];
+ }
+
+ $settings = [];
+
+ if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) {
+ $settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']);
+ }
+
+ if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
+ $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
+ } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
+ $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
+ }
+
+ $settings[] = $this->normalizeStandard($snapshot);
+
+ if (! empty($resultWarnings)) {
+ $status = 'warning';
+ }
+
+ return [
+ 'status' => $status,
+ 'settings' => array_values(array_filter($settings)),
+ 'warnings' => array_values(array_unique($resultWarnings)),
+ ];
+ }
+
+ /**
+ * Flatten normalized settings into key/value pairs for diffing.
+ *
+ * @return array
+ */
+ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
+ {
+ $normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
+ $map = [];
+
+ foreach ($normalized['settings'] as $block) {
+ if (($block['type'] ?? null) === 'table') {
+ foreach ($block['rows'] ?? [] as $row) {
+ $key = $row['path'] ?? $row['label'] ?? 'entry';
+ $map[$key] = $row['value'] ?? null;
+ }
+
+ continue;
+ }
+
+ foreach ($block['entries'] ?? [] as $entry) {
+ $key = $entry['key'] ?? 'entry';
+ $map[$key] = $entry['value'] ?? null;
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param array> $omaSettings
+ */
+ private function normalizeOmaSettings(array $omaSettings): array
+ {
+ $rows = [];
+
+ foreach ($omaSettings as $setting) {
+ if (! is_array($setting)) {
+ continue;
+ }
+
+ $rows[] = [
+ 'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a',
+ 'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null,
+ 'label' => $setting['displayName'] ?? null,
+ 'description' => $setting['description'] ?? null,
+ ];
+ }
+
+ return [
+ 'type' => 'table',
+ 'title' => 'OMA-URI settings',
+ 'rows' => $rows,
+ ];
+ }
+
+ /**
+ * @param array> $settings
+ */
+ private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array
+ {
+ $entries = [];
+
+ foreach ($settings as $setting) {
+ if (! is_array($setting)) {
+ continue;
+ }
+
+ $key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting';
+ $value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null;
+
+ if ($value === null && isset($setting['value']['value'])) {
+ $value = $setting['value']['value'];
+ }
+
+ if (is_array($value)) {
+ $value = json_encode($value, JSON_PRETTY_PRINT);
+ }
+
+ $entries[] = [
+ 'key' => $key,
+ 'value' => $value,
+ ];
+ }
+
+ return [
+ 'type' => 'keyValue',
+ 'title' => $title,
+ 'entries' => $entries,
+ ];
+ }
+
+ private function normalizeStandard(array $snapshot): array
+ {
+ $metadataKeys = [
+ '@odata.context',
+ '@odata.type',
+ 'id',
+ 'version',
+ 'createdDateTime',
+ 'lastModifiedDateTime',
+ 'supportsScopeTags',
+ 'roleScopeTagIds',
+ 'assignments',
+ 'createdBy',
+ 'lastModifiedBy',
+ 'omaSettings',
+ 'settings',
+ 'settingsDelta',
+ ];
+
+ $filtered = Arr::except($snapshot, $metadataKeys);
+ $entries = [];
+
+ foreach ($filtered as $key => $value) {
+ if (is_array($value)) {
+ $value = json_encode($value, JSON_PRETTY_PRINT);
+ }
+
+ $entries[] = [
+ 'key' => Str::headline((string) $key),
+ 'value' => $value,
+ ];
+ }
+
+ return [
+ 'type' => 'keyValue',
+ 'title' => 'General',
+ 'entries' => $entries,
+ ];
+ }
+}
diff --git a/app/Services/Intune/RbacHealthService.php b/app/Services/Intune/RbacHealthService.php
new file mode 100644
index 0000000..bf9ea90
--- /dev/null
+++ b/app/Services/Intune/RbacHealthService.php
@@ -0,0 +1,166 @@
+isActive()) {
+ return $this->record($tenant, 'error', RbacReason::MissingArtifacts->value, false);
+ }
+
+ $artifactsPresent = filled($tenant->rbac_group_id) || filled($tenant->rbac_role_assignment_id);
+
+ if (! $artifactsPresent) {
+ return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
+ }
+
+ $context = $tenant->graphOptions();
+
+ $spId = $this->resolveServicePrincipalId($tenant, $context);
+ if (! $spId) {
+ return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
+ }
+
+ if ($tenant->rbac_role_assignment_id) {
+ $response = $this->graph->request('GET', "deviceManagement/roleAssignments/{$tenant->rbac_role_assignment_id}", $context);
+
+ if ($response->successful()) {
+ $assignment = $response->data;
+
+ if (! $this->matchesScope($assignment, $tenant)) {
+ return $this->record($tenant, 'partial', RbacReason::ScopeMismatch->value, true);
+ }
+
+ if (! $this->matchesRole($assignment, $tenant, $context)) {
+ return $this->record($tenant, 'partial', RbacReason::RoleMismatch->value, true);
+ }
+
+ if (! $this->assignmentIncludesGroup($assignment, $tenant)) {
+ return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
+ }
+
+ return $this->record($tenant, 'ok', null, true);
+ }
+ }
+
+ if ($tenant->rbac_group_id) {
+ $response = $this->graph->request('GET', "groups/{$tenant->rbac_group_id}", $context);
+
+ if ($response->failed()) {
+ return $this->record($tenant, 'error', RbacReason::GroupMissing->value, true);
+ }
+
+ if (! $this->groupHasServicePrincipal($tenant->rbac_group_id, $spId, $context)) {
+ return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
+ }
+
+ // If group exists and SP is a member, but no role assignment found via API,
+ // check if this tenant requires manual assignment (unsupported API)
+ $hasManualAssignmentWarning = is_array($tenant->rbac_last_warnings)
+ && in_array('manual_role_assignment_required', $tenant->rbac_last_warnings, true);
+
+ if ($tenant->rbac_status_reason === RbacReason::ManualAssignmentRequired->value || $hasManualAssignmentWarning) {
+ // Keep the manual_assignment_required status - group and membership are OK
+ // This account type doesn't support the Intune RBAC API, but permissions work via app registration
+ return $this->record($tenant, 'manual_assignment_required', RbacReason::ManualAssignmentRequired->value, true);
+ }
+ }
+
+ return $this->record($tenant, 'missing', RbacReason::AssignmentMissing->value, true);
+ }
+
+ /**
+ * @return array{status:string,reason:?string,used_artifacts:bool}
+ */
+ private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
+ {
+ $tenant->update([
+ 'rbac_status' => $status,
+ 'rbac_status_reason' => $reason,
+ 'rbac_last_checked_at' => CarbonImmutable::now(),
+ ]);
+
+ return [
+ 'status' => $status,
+ 'reason' => $reason,
+ 'used_artifacts' => $usedArtifacts,
+ ];
+ }
+
+ private function resolveServicePrincipalId(Tenant $tenant, array $context): ?string
+ {
+ $response = $this->graph->request('GET', 'servicePrincipals', [
+ 'query' => [
+ '$filter' => "appId eq '{$tenant->app_client_id}'",
+ ],
+ ] + $context);
+
+ return $response->successful()
+ ? ($response->data['value'][0]['id'] ?? null)
+ : null;
+ }
+
+ private function groupHasServicePrincipal(string $groupId, string $spId, array $context): bool
+ {
+ $response = $this->graph->request('GET', "groups/{$groupId}/members", $context);
+
+ if (! $response->successful()) {
+ return false;
+ }
+
+ $members = $response->data['value'] ?? [];
+
+ return collect($members)->contains(fn ($member) => ($member['id'] ?? null) === $spId);
+ }
+
+ private function matchesScope(array $assignment, Tenant $tenant): bool
+ {
+ $scopes = $assignment['resourceScopes'] ?? [];
+ $expected = ($tenant->rbac_scope_mode === 'scope_group' && filled($tenant->rbac_scope_id))
+ ? [$tenant->rbac_scope_id]
+ : ['/'];
+
+ return $scopes === $expected;
+ }
+
+ private function matchesRole(array $assignment, Tenant $tenant, array $context): bool
+ {
+ $roleDefinitionId = data_get($assignment, 'roleDefinition.id') ?? data_get($assignment, 'roleDefinitionId');
+ $expectedId = $tenant->rbac_role_definition_id;
+
+ if (! $expectedId || ! $roleDefinitionId) {
+ return true;
+ }
+
+ return strcasecmp($expectedId, $roleDefinitionId) === 0;
+ }
+
+ private function assignmentIncludesGroup(array $assignment, Tenant $tenant): bool
+ {
+ $bindingBase = sprintf('https://graph.microsoft.com/%s', trim(config('graph.version', 'beta'), '/'));
+ $expected = "{$bindingBase}/groups/{$tenant->rbac_group_id}";
+ $members = $assignment['members@odata.bind'] ?? $assignment['members'] ?? [];
+
+ if (empty($members) && isset($assignment['members@odata.bind'])) {
+ $members = $assignment['members@odata.bind'];
+ }
+
+ if (isset($assignment['members']) && is_array($assignment['members'])) {
+ return collect($assignment['members'])->contains(fn ($id) => $id === $tenant->rbac_group_id);
+ }
+
+ return collect($members)->contains($expected);
+ }
+}
diff --git a/app/Services/Intune/RbacOnboardingService.php b/app/Services/Intune/RbacOnboardingService.php
new file mode 100644
index 0000000..2545955
--- /dev/null
+++ b/app/Services/Intune/RbacOnboardingService.php
@@ -0,0 +1,757 @@
+,service_principal_id:?string,group_id:?string,role_definition_id:?string,role_display_name:?string,role_assignment_id:?string,steps:array}
+ */
+ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $accessToken = null): array
+ {
+ if (! $tenant->isActive()) {
+ return $this->failure($tenant, 'Tenant is not active', $actor);
+ }
+
+ if (empty($tenant->app_client_id)) {
+ return $this->failure($tenant, 'Tenant is missing app_client_id', $actor);
+ }
+
+ if (empty($accessToken)) {
+ return $this->failure($tenant, 'Delegated access token missing. Please sign in first.', $actor);
+ }
+
+ $roleDefinitionId = $input['role_definition_id'] ?? null;
+ $roleDisplayName = $input['role_display_name'] ?? null;
+
+ if (! $roleDefinitionId) {
+ return $this->failure($tenant, 'Select an Intune RBAC role (roleDefinitionId required). Login to load roles.', $actor);
+ }
+
+ $context = $tenant->graphOptions();
+ $context['access_token'] = $accessToken;
+ $result = [
+ 'status' => 'success',
+ 'warnings' => [],
+ 'service_principal_id' => null,
+ 'group_id' => null,
+ 'role_definition_id' => $roleDefinitionId,
+ 'role_display_name' => $roleDisplayName,
+ 'role_assignment_id' => null,
+ 'steps' => [],
+ 'canaries' => [],
+ ];
+
+ $this->audit($tenant, 'rbac.setup.started', [
+ 'role_definition_id' => $roleDefinitionId,
+ 'role_display_name' => $roleDisplayName,
+ 'scope' => $input['scope'] ?? null,
+ ], 'success', $actor);
+
+ try {
+ $servicePrincipal = $this->resolveServicePrincipal($tenant->app_client_id, $context);
+ $result['service_principal_id'] = $servicePrincipal['id'];
+ $result['steps'][] = 'service_principal_resolved';
+
+ $group = $this->ensureGroup($input, $context);
+ $result['group_id'] = $group['id'] ?? null;
+ $result['steps'][] = 'group_resolved';
+
+ $this->ensureGroupMembership($group['id'], $servicePrincipal['id'], $context);
+ $result['steps'][] = 'group_membership';
+
+ $roleDefinition = [
+ 'id' => $roleDefinitionId,
+ 'displayName' => $roleDisplayName,
+ ];
+ $result['steps'][] = 'role_definition_selected';
+
+ try {
+ $assignment = $this->ensureRoleAssignment(
+ roleDefinitionId: $roleDefinition['id'],
+ groupId: $group['id'],
+ groupDisplayName: $group['displayName'] ?? null,
+ scope: $input['scope'] ?? 'all_devices',
+ scopeGroupId: $input['scope_group_id'] ?? null,
+ context: $context
+ );
+
+ $result['role_assignment_id'] = $assignment['id'] ?? null;
+ $result['steps'][] = $assignment['action'];
+ $manualAssignmentRequired = false;
+ } catch (RuntimeException $e) {
+ // Check if this is the unsupported API error
+ if (str_contains($e->getMessage(), 'not support') && str_contains($e->getMessage(), 'account type')) {
+ // Partial success - group and membership created, but role assignment needs manual setup
+ $result['steps'][] = 'role_assignment_manual_required';
+ $result['warnings'][] = 'manual_role_assignment_required';
+ $manualAssignmentRequired = true;
+ $result['message'] = $e->getMessage();
+ } else {
+ // Re-throw other errors
+ throw $e;
+ }
+ }
+
+ $postCheck = $this->postCheck($tenant, $context, $input['scope'] ?? 'all_devices', $actor);
+ $result['canaries'] = $postCheck['canaries'] ?? [];
+ $result['warnings'] = array_merge(
+ $result['warnings'],
+ $postCheck['warnings'] ?? [],
+ (($input['scope'] ?? null) === 'scope_group') ? ['scope_limited'] : []
+ );
+
+ $hasCanaryError = collect($result['canaries'])->contains(fn ($status) => $status === 'error');
+
+ // Determine status based on what succeeded
+ if ($manualAssignmentRequired) {
+ $status = 'manual_assignment_required';
+ $statusReason = RbacReason::ManualAssignmentRequired->value;
+ } elseif ($hasCanaryError) {
+ $status = 'partial';
+ $statusReason = RbacReason::CanaryFailed->value;
+ } else {
+ $status = 'ok';
+ $statusReason = null;
+ }
+
+ // Update result status to match what's being persisted
+ $result['status'] = $status;
+
+ $this->persistArtifacts($tenant, [
+ 'group_id' => $group['id'] ?? $input['existing_group_id'] ?? null,
+ 'role_assignment_id' => $result['role_assignment_id'] ?? null,
+ 'role_definition_id' => $roleDefinition['id'] ?? null,
+ 'role_display_name' => $roleDefinition['displayName'] ?? null,
+ 'scope_mode' => $input['scope'] ?? 'all_devices',
+ 'scope_id' => $input['scope_group_id'] ?? null,
+ 'warnings' => $result['warnings'],
+ 'canaries' => $result['canaries'],
+ 'executed_by' => $actor?->id,
+ 'status' => $status,
+ 'status_reason' => $statusReason,
+ ]);
+
+ $this->audit($tenant, 'rbac.setup.completed', [
+ 'role_definition_id' => $roleDefinition['id'],
+ 'group_id' => $group['id'],
+ 'role_assignment_id' => $assignment['id'] ?? null,
+ ], 'success', $actor);
+ } catch (RuntimeException $exception) {
+ return $this->failure($tenant, $exception->getMessage(), $actor);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array{id:string}
+ */
+ private function resolveServicePrincipal(string $appClientId, array $context): array
+ {
+ $response = $this->graph->request('GET', 'servicePrincipals', [
+ 'query' => [
+ '$filter' => "appId eq '{$appClientId}'",
+ ],
+ ] + $context);
+
+ if ($response->failed()) {
+ throw new RuntimeException('Failed to resolve service principal: '.json_encode($response->errors));
+ }
+
+ $servicePrincipal = $response->data['value'][0] ?? null;
+
+ if (! $servicePrincipal || empty($servicePrincipal['id'])) {
+ throw new RuntimeException('Service principal not found for app_client_id');
+ }
+
+ return $servicePrincipal;
+ }
+
+ /**
+ * @param array{group_mode?:string,existing_group_id?:?string,group_name?:?string} $input
+ * @return array{id:string}
+ */
+ private function ensureGroup(array $input, array $context): array
+ {
+ $mode = $input['group_mode'] ?? 'create';
+ $groupName = $input['group_name'] ?? self::DEFAULT_GROUP_NAME;
+
+ if ($mode === 'existing' && ! empty($input['existing_group_id'])) {
+ return ['id' => $input['existing_group_id']];
+ }
+
+ $existing = $this->graph->request('GET', 'groups', [
+ 'query' => [
+ '$filter' => "displayName eq '{$groupName}'",
+ ],
+ ] + $context);
+
+ if ($existing->successful() && ! empty($existing->data['value'][0]['id'])) {
+ return $existing->data['value'][0];
+ }
+
+ $response = $this->graph->request('POST', 'groups', [
+ 'json' => [
+ 'displayName' => $groupName,
+ 'mailEnabled' => false,
+ 'mailNickname' => $this->mailNickname($groupName),
+ 'securityEnabled' => true,
+ ],
+ ] + $context);
+
+ if ($response->failed() || empty($response->data['id'])) {
+ throw new RuntimeException('Failed to create or find security group: '.json_encode($response->errors));
+ }
+
+ return $response->data;
+ }
+
+ private function ensureGroupMembership(string $groupId, string $servicePrincipalId, array $context): void
+ {
+ $path = "groups/{$groupId}/members/\$ref";
+
+ $response = $this->graph->request('POST', $path, [
+ 'json' => [
+ '@odata.id' => "https://graph.microsoft.com/v1.0/directoryObjects/{$servicePrincipalId}",
+ ],
+ ] + $context);
+
+ if ($response->failed()) {
+ if ($this->referenceAlreadyExists($response->errors, $response->data)) {
+ return;
+ }
+
+ $status = $response->status ?? 'unknown';
+ $error = $this->extractErrorMessage($response->errors, $response->data);
+
+ throw new RuntimeException(sprintf(
+ 'step=ensureGroupMembership path=/%s status=%s error=%s',
+ $path,
+ $status,
+ $error
+ ));
+ }
+ }
+
+ private function referenceAlreadyExists(array $errors, array $data): bool
+ {
+ if ($this->isExistingReferenceError($data['error'] ?? [])) {
+ return true;
+ }
+
+ if (isset($data['error']) && is_string($data['error'])) {
+ $decoded = $this->decodeJsonString($data['error']);
+ if ($this->isExistingReferenceError($decoded['error'] ?? $decoded)) {
+ return true;
+ }
+ }
+
+ foreach ($errors as $error) {
+ if (is_array($error) && $this->isExistingReferenceError($error['error'] ?? $error)) {
+ return true;
+ }
+
+ if (is_string($error) && $this->containsReferenceExistsString($error)) {
+ return true;
+ }
+
+ if (is_string($error)) {
+ $decoded = $this->decodeJsonString($error);
+ if ($this->isExistingReferenceError($decoded['error'] ?? $decoded)) {
+ return true;
+ }
+
+ if ($this->containsReferenceExistsString(json_encode($decoded) ?: '')) {
+ return true;
+ }
+ }
+ }
+
+ if ($this->containsReferenceExistsString(json_encode($data) ?: '')) {
+ return true;
+ }
+
+ if ($this->containsReferenceExistsString(json_encode($errors) ?: '')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private function isExistingReferenceError(array $payload): bool
+ {
+ $code = $payload['code'] ?? null;
+ $message = $payload['message'] ?? null;
+
+ return $code === 'Request_BadRequest'
+ && is_string($message)
+ && Str::contains($message, 'added object references already exist', true);
+ }
+
+ private function containsReferenceExistsString(string $value): bool
+ {
+ return Str::contains(strtolower($value), 'added object references already exist');
+ }
+
+ /**
+ * Check if the error indicates an unsupported account type for RBAC API.
+ */
+ private function isUnsupportedAccountTypeError(string $errorMessage, ?int $status): bool
+ {
+ return $status === 400
+ && (Str::contains($errorMessage, 'This API is not supported for AAD accounts', true)
+ || Str::contains($errorMessage, 'no addressUrl for Microsoft.Intune.Rbac', true));
+ }
+
+ /**
+ * @return array
+ */
+ private function decodeJsonString(?string $value): array
+ {
+ if (! is_string($value) || $value === '') {
+ return [];
+ }
+
+ $decoded = json_decode($value, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ private function extractErrorMessage(array $errors, array $data): string
+ {
+ $message = $data['error']['message'] ?? null;
+
+ if (is_string($message) && $message !== '') {
+ return $message;
+ }
+
+ foreach ($errors as $error) {
+ if (is_array($error) && is_string($error['error']['message'] ?? null)) {
+ return $error['error']['message'];
+ }
+
+ if (is_array($error) && is_string($error['message'] ?? null)) {
+ return $error['message'];
+ }
+
+ if (is_string($error)) {
+ $decoded = $this->decodeJsonString($error);
+
+ if (is_string($decoded['error']['message'] ?? null)) {
+ return $decoded['error']['message'];
+ }
+
+ if (is_string($decoded['message'] ?? null)) {
+ return $decoded['message'];
+ }
+ }
+
+ if (is_string($error) && $error !== '') {
+ return $error;
+ }
+ }
+
+ return 'unknown error';
+ }
+
+ /**
+ * @return array{id:?string,action:string}
+ */
+ private function ensureRoleAssignment(string $roleDefinitionId, string $groupId, ?string $groupDisplayName, string $scope, ?string $scopeGroupId, array $context): array
+ {
+ $desiredScopes = $scope === 'scope_group' && $scopeGroupId
+ ? [$scopeGroupId]
+ : ['/'];
+
+ $assignments = $this->graph->request('GET', 'deviceManagement/roleAssignments', [
+ 'query' => [
+ '$select' => 'id,displayName,resourceScopes,members',
+ '$expand' => 'roleDefinition($select=id,displayName)',
+ ],
+ ] + $context);
+
+ if ($assignments->failed()) {
+ $status = $assignments->status ?? 'unknown';
+ $error = $this->extractErrorMessage($assignments->errors, $assignments->data);
+
+ throw new RuntimeException(sprintf(
+ 'step=listRoleAssignments path=/deviceManagement/roleAssignments status=%s error=%s',
+ $status,
+ $error
+ ));
+ }
+
+ $assignmentsWithMembers = $this->hydrateAssignmentMembers($assignments->data['value'] ?? [], $context);
+
+ $matching = collect($assignmentsWithMembers)
+ ->first(function (array $assignment) use ($groupId, $roleDefinitionId) {
+ $definition = $assignment['roleDefinition']['id'] ?? null;
+
+ if (! $definition || strcasecmp($definition, $roleDefinitionId) !== 0) {
+ return false;
+ }
+
+ $members = $this->extractMemberIds($assignment);
+
+ return in_array($groupId, $members, true);
+ });
+
+ $bindingBase = sprintf('https://graph.microsoft.com/%s', trim(config('graph.version', 'beta'), '/'));
+
+ if ($matching) {
+ $currentScopes = $matching['resourceScopes'] ?? [];
+ if ($currentScopes === $desiredScopes) {
+ return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_exists'];
+ }
+
+ $update = $this->graph->request('PATCH', "deviceManagement/roleAssignments/{$matching['id']}", [
+ 'json' => ['resourceScopes' => $desiredScopes],
+ ] + $context);
+
+ if ($update->failed()) {
+ $error = $this->extractErrorMessage($update->errors, $update->data);
+
+ if ($this->isUnsupportedAccountTypeError($error, $update->status)) {
+ throw new RuntimeException(sprintf(
+ 'Automated role assignment updates are not supported for this account type. '
+ .'Please update the role assignment scope manually in the Azure Portal if needed. '
+ .'[Technical: step=updateRoleAssignment id=%s status=%s error=%s]',
+ $matching['id'],
+ $update->status ?? 'unknown',
+ $error
+ ));
+ }
+
+ throw new RuntimeException(sprintf(
+ 'step=updateRoleAssignment path=/deviceManagement/roleAssignments/%s status=%s error=%s',
+ $matching['id'],
+ $update->status ?? 'unknown',
+ $error
+ ));
+ }
+
+ return ['id' => $matching['id'] ?? null, 'action' => 'role_assignment_updated'];
+ }
+
+ $create = $this->graph->request('POST', 'deviceManagement/roleAssignments', [
+ 'json' => [
+ 'displayName' => "TenantPilot RBAC - {$roleDefinitionId}",
+ 'description' => 'TenantPilot automated RBAC setup',
+ 'roleDefinition@odata.bind' => "{$bindingBase}/deviceManagement/roleDefinitions/{$roleDefinitionId}",
+ 'members@odata.bind' => ["{$bindingBase}/groups/{$groupId}"],
+ 'resourceScopes' => $desiredScopes,
+ ],
+ ] + $context);
+
+ if ($create->failed()) {
+ $error = $this->extractErrorMessage($create->errors, $create->data);
+ $requestId = $create->meta['request_id'] ?? null;
+ $clientRequestId = $create->meta['client_request_id'] ?? null;
+
+ // Check for known AAD/Entra ID account limitation
+ if ($this->isUnsupportedAccountTypeError($error, $create->status)) {
+ $groupLabel = $groupDisplayName ? "{$groupDisplayName} (ID: {$groupId})" : $groupId;
+ $details = sprintf(
+ 'The Intune RBAC API does not support automated role assignments for this account type. '
+ .'Setup is partially complete: security group %s has been created and the service principal added as member. '
+ .'The application permissions are already granted and working (verified by canary checks). '
+ .'Note: Manual Intune RBAC role assignment via Azure Portal is not required for functionality - '
+ .'the app can already access Intune resources through the granted API permissions.',
+ $groupLabel
+ );
+
+ if ($requestId || $clientRequestId) {
+ $details .= sprintf(
+ ' [Technical: step=createRoleAssignment status=%s error=%s request_id=%s client_request_id=%s]',
+ $create->status ?? 'unknown',
+ $error,
+ $requestId ?? 'n/a',
+ $clientRequestId ?? 'n/a'
+ );
+ }
+
+ throw new RuntimeException($details);
+ }
+
+ $details = sprintf(
+ 'step=createRoleAssignment path=/deviceManagement/roleAssignments status=%s error=%s',
+ $create->status ?? 'unknown',
+ $error
+ );
+
+ if ($requestId || $clientRequestId) {
+ $details .= sprintf(
+ ' request_id=%s client_request_id=%s',
+ $requestId ?? 'n/a',
+ $clientRequestId ?? 'n/a'
+ );
+ }
+
+ throw new RuntimeException($details);
+ }
+
+ return ['id' => $create->data['id'] ?? null, 'action' => 'role_assignment_created'];
+ }
+
+ /**
+ * @param array> $assignments
+ * @return array>
+ */
+ private function hydrateAssignmentMembers(array $assignments, array $context): array
+ {
+ return collect($assignments)
+ ->map(function (array $assignment) use ($context) {
+ if (empty($assignment['id'])) {
+ $assignment['members'] = $this->extractMemberIds($assignment);
+
+ return $assignment;
+ }
+
+ $members = $this->extractMemberIds($assignment);
+
+ if (! empty($members)) {
+ $assignment['members'] = $members;
+
+ return $assignment;
+ }
+
+ $membersResponse = $this->graph->request('GET', "deviceManagement/roleAssignments/{$assignment['id']}", [
+ 'query' => [
+ '$select' => 'id,displayName,resourceScopes,members',
+ '$expand' => 'roleDefinition($select=id,displayName)',
+ ],
+ ] + $context);
+
+ if ($membersResponse->failed()) {
+ $error = $this->extractErrorMessage($membersResponse->errors, $membersResponse->data);
+
+ Log::warning('rbac.role_assignments.members_missing', [
+ 'assignment_id' => $assignment['id'],
+ 'status' => $membersResponse->status,
+ 'error' => $error,
+ ]);
+
+ $assignment['members'] = [];
+
+ return $assignment;
+ }
+
+ $assignment['members'] = $this->extractMemberIds($membersResponse->data ?? []);
+
+ if (empty($assignment['members'])) {
+ $assignment['members'] = $this->extractMemberIds($membersResponse->data['value'] ?? []);
+ }
+
+ return $assignment;
+ })
+ ->all();
+ }
+
+ /**
+ * @return array
+ */
+ private function extractMemberIds(array $assignment): array
+ {
+ $members = [];
+
+ $rawMembers = $assignment['members'] ?? [];
+
+ if (is_array($rawMembers)) {
+ foreach ($rawMembers as $member) {
+ if (is_array($member) && isset($member['id'])) {
+ $members[] = (string) $member['id'];
+ }
+
+ if (is_string($member)) {
+ $members[] = $this->trimBindingId($member);
+ }
+ }
+ }
+
+ $bindings = $assignment['members@odata.bind'] ?? [];
+ if (is_array($bindings)) {
+ foreach ($bindings as $binding) {
+ if (is_string($binding)) {
+ $members[] = $this->trimBindingId($binding);
+ }
+ }
+ }
+
+ return array_values(array_unique(array_filter($members)));
+ }
+
+ private function trimBindingId(string $binding): string
+ {
+ if (str_contains($binding, '/')) {
+ return (string) Str::afterLast($binding, '/');
+ }
+
+ return $binding;
+ }
+
+ /**
+ * @param array{group_id:?string,role_assignment_id:?string,role_definition_id:?string,role_display_name:?string,scope_mode:?string,scope_id:?string,warnings?:array,canaries?:array,executed_by?:?int} $artifacts
+ */
+ private function persistArtifacts(Tenant $tenant, array $artifacts): void
+ {
+ $canaries = $artifacts['canaries'] ?? [];
+
+ if (! array_key_exists('deviceConfigurations', $canaries)) {
+ $canaries['deviceConfigurations'] = 'ok';
+ }
+
+ if (! array_key_exists('deviceCompliancePolicies', $canaries)) {
+ $canaries['deviceCompliancePolicies'] = 'ok';
+ }
+
+ if (config('tenantpilot.features.conditional_access', false)) {
+ if (! array_key_exists('conditionalAccess', $canaries)) {
+ $canaries['conditionalAccess'] = 'ok';
+ }
+ } else {
+ $canaries['conditionalAccess'] = 'skipped';
+ }
+
+ $warnings = array_values(array_unique($artifacts['warnings'] ?? []));
+
+ if (config('tenantpilot.features.conditional_access', false) === false && ! in_array('ca_canary_disabled', $warnings, true)) {
+ $warnings[] = 'ca_canary_disabled';
+ }
+
+ if ((($artifacts['scope_mode'] ?? null) === 'scope_group' || filled($artifacts['scope_id'] ?? null))) {
+ $warnings[] = 'scope_limited';
+ }
+
+ $status = $artifacts['status'] ?? null;
+ $statusReason = $artifacts['status_reason'] ?? null;
+
+ if ($status === null) {
+ $hasArtifacts = filled($artifacts['group_id'] ?? null) && filled($artifacts['role_assignment_id'] ?? null);
+ $status = $hasArtifacts ? 'ok' : 'missing';
+ }
+
+ if ($status === 'missing' && $statusReason === null) {
+ $statusReason = RbacReason::MissingArtifacts->value;
+ }
+
+ $tenant->update([
+ 'rbac_group_id' => $artifacts['group_id'] ?? $artifacts['scope_id'] ?? null,
+ 'rbac_role_assignment_id' => $artifacts['role_assignment_id'] ?? null,
+ 'rbac_role_definition_id' => $artifacts['role_definition_id'] ?? null,
+ 'rbac_role_display_name' => $artifacts['role_display_name'] ?? null,
+ 'rbac_role_key' => $artifacts['role_display_name'] ?? $artifacts['role_definition_id'] ?? null,
+ 'rbac_scope_mode' => $artifacts['scope_mode'] ?? null,
+ 'rbac_scope_id' => $artifacts['scope_id'] ?? null,
+ 'rbac_last_warnings' => $warnings,
+ 'rbac_canary_results' => $canaries,
+ 'rbac_last_setup_by' => $artifacts['executed_by'] ?? null,
+ 'rbac_last_setup_at' => now(),
+ 'rbac_status' => $status,
+ 'rbac_status_reason' => $statusReason,
+ ]);
+ }
+
+ /**
+ * @return array{canaries: array, warnings: array}
+ */
+ private function postCheck(Tenant $tenant, array $context, string $scopeMode, ?User $actor = null): array
+ {
+ $this->audit($tenant, 'rbac.verify.started', ['scope_mode' => $scopeMode], 'success', $actor);
+
+ $canaries = [
+ 'deviceConfigurations' => 'deviceManagement/deviceConfigurations?$top=1',
+ 'deviceCompliancePolicies' => 'deviceManagement/deviceCompliancePolicies?$top=1',
+ ];
+
+ $warnings = [];
+
+ if (config('tenantpilot.features.conditional_access', false)) {
+ $canaries['conditionalAccess'] = 'identity/conditionalAccess/policies?$top=1';
+ } else {
+ $warnings[] = 'ca_canary_disabled';
+ }
+
+ $errors = [];
+ $results = [];
+
+ foreach ($canaries as $key => $path) {
+ $response = $this->graph->request('GET', $path, $context);
+
+ if ($response->failed()) {
+ $errors[] = $key;
+ $results[$key] = 'error';
+
+ continue;
+ }
+
+ $results[$key] = 'ok';
+ }
+
+ $this->audit($tenant, 'rbac.verify.completed', [
+ 'errors' => $errors,
+ 'scope_mode' => $scopeMode,
+ ], empty($errors) ? 'success' : 'error', $actor);
+
+ return [
+ 'canaries' => $results,
+ 'warnings' => $warnings,
+ ];
+ }
+
+ private function mailNickname(string $groupName): string
+ {
+ $nickname = Str::slug($groupName, '_');
+
+ return $nickname ?: 'tenantpilot_intune_rbac';
+ }
+
+ private function failure(Tenant $tenant, string $message, ?User $actor = null): array
+ {
+ $this->audit($tenant, 'rbac.setup.failed', ['error' => $message], 'error', $actor);
+
+ return [
+ 'status' => 'error',
+ 'message' => $message,
+ 'warnings' => [],
+ 'service_principal_id' => null,
+ 'group_id' => null,
+ 'role_definition_id' => null,
+ 'role_assignment_id' => null,
+ 'steps' => [],
+ ];
+ }
+
+ private function audit(Tenant $tenant, string $action, array $context, string $status, ?User $actor = null): void
+ {
+ $this->auditLogger->log(
+ tenant: $tenant,
+ action: $action,
+ resourceType: 'tenant',
+ resourceId: (string) $tenant->id,
+ status: $status,
+ context: ['metadata' => $context],
+ actorId: $actor?->id,
+ actorEmail: $actor?->email,
+ actorName: $actor?->name,
+ );
+ }
+}
diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php
index 42088bf..5fb4541 100644
--- a/app/Services/Intune/RestoreService.php
+++ b/app/Services/Intune/RestoreService.php
@@ -21,6 +21,7 @@ public function __construct(
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
private readonly VersionService $versionService,
+ private readonly SnapshotValidator $snapshotValidator,
) {}
/**
@@ -48,6 +49,11 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
'platform' => $item->platform,
'action' => $existing ? 'update' : 'create',
'conflict' => false,
+ 'validation_warning' => BackupItem::odataTypeWarning(
+ is_array($item->payload) ? $item->payload : [],
+ $item->policy_type,
+ $item->platform
+ ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
];
})->all();
}
@@ -94,6 +100,27 @@ public function execute(
'backup_item_id' => $item->id,
];
+ $odataValidation = BackupItem::validateODataType(
+ is_array($item->payload) ? $item->payload : [],
+ $item->policy_type,
+ $item->platform
+ );
+
+ if (! $odataValidation['matches']) {
+ $results[] = $context + [
+ 'status' => 'failed',
+ 'reason' => BackupItem::odataTypeWarning(
+ is_array($item->payload) ? $item->payload : [],
+ $item->policy_type,
+ $item->platform
+ ) ?? 'Snapshot type mismatch',
+ 'code' => 'odata_mismatch',
+ ];
+ $failures++;
+
+ continue;
+ }
+
if ($dryRun) {
$results[] = $context + ['status' => 'dry_run'];
diff --git a/app/Services/Intune/SnapshotValidator.php b/app/Services/Intune/SnapshotValidator.php
new file mode 100644
index 0000000..8fa661b
--- /dev/null
+++ b/app/Services/Intune/SnapshotValidator.php
@@ -0,0 +1,43 @@
+, errors: array}
+ */
+ public function validate(array $snapshot): array
+ {
+ $warnings = [];
+ $errors = [];
+
+ if ($snapshot === []) {
+ $warnings[] = 'Snapshot is empty';
+ }
+
+ if (array_is_list($snapshot)) {
+ $warnings[] = 'This snapshot may be incomplete or malformed';
+ }
+
+ foreach ($snapshot as $value) {
+ if (is_string($value) && $this->looksSerializedJson($value)) {
+ $warnings[] = 'Snapshot may have been stored as a serialized string';
+ break;
+ }
+ }
+
+ return [
+ 'isValid' => empty($errors),
+ 'warnings' => array_values(array_unique($warnings)),
+ 'errors' => array_values(array_unique($errors)),
+ ];
+ }
+
+ private function looksSerializedJson(string $value): bool
+ {
+ $trimmed = trim($value);
+
+ return str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[');
+ }
+}
diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php
index fcd31d3..43760c3 100644
--- a/app/Services/Intune/TenantPermissionService.php
+++ b/app/Services/Intune/TenantPermissionService.php
@@ -4,9 +4,12 @@
use App\Models\Tenant;
use App\Models\TenantPermission;
+use App\Services\Graph\GraphClientInterface;
class TenantPermissionService
{
+ public function __construct(private readonly GraphClientInterface $graphClient) {}
+
/**
* @return array}>
*/
@@ -35,12 +38,21 @@ public function getGrantedPermissions(Tenant $tenant): array
/**
* @param array|null}|string>|null $grantedStatuses
* @param bool $persist Persist comparison results to tenant_permissions
+ * @param bool $liveCheck If true, fetch actual permissions from Graph API
* @return array{overall_status:string,permissions:array,status:string,details:array|null}>}
*/
- public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true): array
+ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true, bool $liveCheck = false): array
{
$required = $this->getRequiredPermissions();
- $granted = $this->normalizeGrantedStatuses($grantedStatuses ?? $this->getGrantedPermissions($tenant));
+
+ // If liveCheck is requested, fetch actual permissions from Graph
+ if ($liveCheck && $grantedStatuses === null) {
+ $grantedStatuses = $this->fetchLivePermissions($tenant);
+ }
+
+ $granted = $this->normalizeGrantedStatuses(
+ $grantedStatuses ?? array_replace_recursive($this->configuredGrantedStatuses(), $this->getGrantedPermissions($tenant))
+ );
$results = [];
$hasMissing = false;
$hasErrors = false;
@@ -113,4 +125,78 @@ private function normalizeGrantedStatuses(array $granted): array
return $normalized;
}
+
+ /**
+ * @return array|null}>
+ */
+ public function configuredGrantedStatuses(): array
+ {
+ $configured = $this->configuredGrantedKeys();
+ $normalized = [];
+
+ foreach ($configured as $key) {
+ $normalized[$key] = [
+ 'status' => 'ok',
+ 'details' => ['source' => 'configured'],
+ ];
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * @return array
+ */
+ private function configuredGrantedKeys(): array
+ {
+ $env = env('INTUNE_GRANTED_PERMISSIONS');
+
+ if (is_string($env) && filled($env)) {
+ return collect(explode(',', $env))
+ ->map(fn (string $key) => trim($key))
+ ->filter()
+ ->values()
+ ->all();
+ }
+
+ return config('intune_permissions.granted_stub', []);
+ }
+
+ /**
+ * Fetch actual granted permissions from Graph API.
+ *
+ * @return array|null}>
+ */
+ private function fetchLivePermissions(Tenant $tenant): array
+ {
+ try {
+ $response = $this->graphClient->getServicePrincipalPermissions(
+ $tenant->graphOptions()
+ );
+
+ if (! $response->success) {
+ return [];
+ }
+
+ $grantedPermissions = $response->data['permissions'] ?? [];
+ $normalized = [];
+
+ foreach ($grantedPermissions as $permission) {
+ $normalized[$permission] = [
+ 'status' => 'ok',
+ 'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()],
+ ];
+ }
+
+ return $normalized;
+ } catch (\Throwable $e) {
+ // Log error but don't fail - fall back to config
+ \Log::warning('Failed to fetch live permissions from Graph', [
+ 'tenant_id' => $tenant->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
}
diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php
new file mode 100644
index 0000000..dd2e0e1
--- /dev/null
+++ b/app/Support/Concerns/InteractsWithODataTypes.php
@@ -0,0 +1,115 @@
+>
+ */
+ protected static function odataTypeMap(): array
+ {
+ return [
+ 'deviceConfiguration' => [
+ 'windows' => '#microsoft.graph.windows10CustomConfiguration',
+ 'ios' => '#microsoft.graph.iosGeneralDeviceConfiguration',
+ 'android' => '#microsoft.graph.androidGeneralDeviceConfiguration',
+ 'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration',
+ 'all' => '#microsoft.graph.deviceConfiguration',
+ ],
+ 'deviceCompliancePolicy' => [
+ 'windows' => '#microsoft.graph.windows10CompliancePolicy',
+ 'ios' => '#microsoft.graph.iosCompliancePolicy',
+ 'android' => '#microsoft.graph.androidCompliancePolicy',
+ 'macOS' => '#microsoft.graph.macOSCompliancePolicy',
+ 'all' => '#microsoft.graph.deviceCompliancePolicy',
+ ],
+ 'appProtectionPolicy' => [
+ 'mobile' => '#microsoft.graph.targetedManagedAppProtection',
+ 'all' => '#microsoft.graph.targetedManagedAppProtection',
+ ],
+ 'conditionalAccessPolicy' => [
+ 'all' => '#microsoft.graph.conditionalAccessPolicy',
+ ],
+ 'deviceManagementScript' => [
+ 'windows' => '#microsoft.graph.deviceManagementScript',
+ ],
+ 'enrollmentRestriction' => [
+ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
+ ],
+ 'windowsAutopilotDeploymentProfile' => [
+ 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile',
+ ],
+ 'windowsEnrollmentStatusPage' => [
+ 'windows' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
+ ],
+ 'endpointSecurityIntent' => [
+ 'windows' => '#microsoft.graph.deviceManagementIntent',
+ ],
+ 'mobileApp' => [
+ 'all' => '#microsoft.graph.mobileApp',
+ ],
+ ];
+ }
+
+ public static function expectedODataType(?string $policyType, ?string $platform = null): ?string
+ {
+ if (! $policyType) {
+ return null;
+ }
+
+ $map = static::odataTypeMap();
+ $types = $map[$policyType] ?? null;
+
+ if ($types === null) {
+ return null;
+ }
+
+ $platformKey = $platform ?: 'all';
+
+ return $types[$platformKey] ?? $types['all'] ?? null;
+ }
+
+ /**
+ * @return array{matches: bool, expected: ?string, actual: ?string}
+ */
+ public static function validateODataType(array $snapshot, ?string $policyType = null, ?string $platform = null): array
+ {
+ $expected = static::expectedODataType($policyType, $platform);
+ $actual = $snapshot['@odata.type'] ?? null;
+
+ if ($expected === null || $actual === null) {
+ return [
+ 'matches' => true,
+ 'expected' => $expected,
+ 'actual' => $actual,
+ ];
+ }
+
+ return [
+ 'matches' => strcasecmp($actual, $expected) === 0,
+ 'expected' => $expected,
+ 'actual' => $actual,
+ ];
+ }
+
+ public static function odataTypeWarning(array $snapshot, ?string $policyType = null, ?string $platform = null): ?string
+ {
+ $validation = static::validateODataType($snapshot, $policyType, $platform);
+
+ if ($validation['matches']) {
+ return null;
+ }
+
+ if (! $validation['expected'] || ! $validation['actual']) {
+ return null;
+ }
+
+ return sprintf(
+ '@odata.type mismatch: expected %s for %s, got %s',
+ $validation['expected'],
+ $policyType ?? 'policy',
+ $validation['actual']
+ );
+ }
+}
diff --git a/app/Support/RbacReason.php b/app/Support/RbacReason.php
new file mode 100644
index 0000000..58af822
--- /dev/null
+++ b/app/Support/RbacReason.php
@@ -0,0 +1,17 @@
+ 'DeviceManagementConfiguration.ReadWrite.All',
'type' => 'application',
'description' => 'Read and write Intune device configuration policies.',
- 'features' => ['policy-sync', 'backup', 'restore'],
+ 'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization'],
+ ],
+ [
+ 'key' => 'DeviceManagementConfiguration.Read.All',
+ 'type' => 'application',
+ 'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
+ 'features' => ['policy-sync', 'backup', 'settings-normalization'],
],
[
'key' => 'DeviceManagementApps.ReadWrite.All',
@@ -14,6 +20,36 @@
'description' => 'Manage app configuration and assignments for Intune.',
'features' => ['backup', 'restore'],
],
+ [
+ 'key' => 'DeviceManagementApps.Read.All',
+ 'type' => 'application',
+ 'description' => 'Read app configuration and assignments for Intune.',
+ 'features' => ['policy-sync', 'backup'],
+ ],
+ [
+ 'key' => 'DeviceManagementServiceConfig.ReadWrite.All',
+ 'type' => 'application',
+ 'description' => 'Manage enrollment restrictions, Autopilot, ESP, and related service configs.',
+ 'features' => ['backup', 'restore', 'policy-sync'],
+ ],
+ [
+ 'key' => 'DeviceManagementServiceConfig.Read.All',
+ 'type' => 'application',
+ 'description' => 'Read enrollment restrictions, Autopilot, ESP, and related service configs.',
+ 'features' => ['policy-sync', 'backup'],
+ ],
+ [
+ 'key' => 'Policy.Read.All',
+ 'type' => 'application',
+ 'description' => 'Read Conditional Access policies for preview/backup.',
+ 'features' => ['conditional-access', 'backup', 'versioning'],
+ ],
+ [
+ 'key' => 'Policy.ReadWrite.ConditionalAccess',
+ 'type' => 'application',
+ 'description' => 'Manage Conditional Access policies (used for preview-only or admin-controlled restores).',
+ 'features' => ['conditional-access', 'restore'],
+ ],
[
'key' => 'Directory.Read.All',
'type' => 'application',
@@ -21,4 +57,25 @@
'features' => ['tenant-health'],
],
],
+ // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI).
+ // Diese Liste sollte mit den tatsächlich in Entra ID granted permissions übereinstimmen.
+ // HINWEIS: In Produktion sollte dies dynamisch von Graph API abgerufen werden (geplant für v1.1+).
+ 'granted_stub' => [
+ // Tatsächlich granted (aus Entra ID Screenshot):
+ 'Device.Read.All',
+ 'DeviceManagementConfiguration.Read.All',
+ 'DeviceManagementConfiguration.ReadWrite.All',
+ 'DeviceManagementManagedDevices.ReadWrite.All',
+ 'DeviceManagementServiceConfig.Read.All',
+ 'Directory.Read.All',
+ 'User.Read',
+
+ // Required permissions (müssen in Entra ID granted werden):
+ // Wenn diese fehlen, erscheinen sie als "missing" in der UI
+ 'DeviceManagementApps.ReadWrite.All',
+ 'DeviceManagementApps.Read.All',
+ 'DeviceManagementServiceConfig.ReadWrite.All',
+ 'Policy.Read.All',
+ 'Policy.ReadWrite.ConditionalAccess',
+ ],
];
diff --git a/config/tenantpilot.php b/config/tenantpilot.php
index 68180ea..0a45f4a 100644
--- a/config/tenantpilot.php
+++ b/config/tenantpilot.php
@@ -104,4 +104,8 @@
'risk' => 'low-medium',
],
],
+
+ 'features' => [
+ 'conditional_access' => true,
+ ],
];
diff --git a/database/migrations/2025_12_12_150000_add_rbac_fields_to_tenants.php b/database/migrations/2025_12_12_150000_add_rbac_fields_to_tenants.php
new file mode 100644
index 0000000..b572f23
--- /dev/null
+++ b/database/migrations/2025_12_12_150000_add_rbac_fields_to_tenants.php
@@ -0,0 +1,35 @@
+string('rbac_group_id')->nullable()->after('app_notes');
+ $table->string('rbac_role_assignment_id')->nullable()->after('rbac_group_id');
+ $table->string('rbac_role_key')->nullable()->after('rbac_role_assignment_id');
+ $table->string('rbac_scope_mode')->nullable()->after('rbac_role_key');
+ $table->string('rbac_scope_id')->nullable()->after('rbac_scope_mode');
+
+ $table->index(['rbac_group_id', 'rbac_role_assignment_id'], 'tenants_rbac_artifacts_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropIndex('tenants_rbac_artifacts_idx');
+ $table->dropColumn([
+ 'rbac_group_id',
+ 'rbac_role_assignment_id',
+ 'rbac_role_key',
+ 'rbac_scope_mode',
+ 'rbac_scope_id',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2025_12_12_151000_add_rbac_status_fields_to_tenants.php b/database/migrations/2025_12_12_151000_add_rbac_status_fields_to_tenants.php
new file mode 100644
index 0000000..7f05918
--- /dev/null
+++ b/database/migrations/2025_12_12_151000_add_rbac_status_fields_to_tenants.php
@@ -0,0 +1,28 @@
+string('rbac_status')->nullable()->after('rbac_scope_id');
+ $table->string('rbac_status_reason')->nullable()->after('rbac_status');
+ $table->timestamp('rbac_last_checked_at')->nullable()->after('rbac_status_reason');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropColumn([
+ 'rbac_status',
+ 'rbac_status_reason',
+ 'rbac_last_checked_at',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2025_12_12_160000_add_rbac_summary_to_tenants.php b/database/migrations/2025_12_12_160000_add_rbac_summary_to_tenants.php
new file mode 100644
index 0000000..f37a8e2
--- /dev/null
+++ b/database/migrations/2025_12_12_160000_add_rbac_summary_to_tenants.php
@@ -0,0 +1,30 @@
+unsignedBigInteger('rbac_last_setup_by')->nullable()->after('rbac_last_checked_at');
+ $table->timestamp('rbac_last_setup_at')->nullable()->after('rbac_last_setup_by');
+ $table->json('rbac_canary_results')->nullable()->after('rbac_last_setup_at');
+ $table->json('rbac_last_warnings')->nullable()->after('rbac_canary_results');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropColumn([
+ 'rbac_last_setup_by',
+ 'rbac_last_setup_at',
+ 'rbac_canary_results',
+ 'rbac_last_warnings',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2025_12_12_170500_add_rbac_role_definition_columns_to_tenants.php b/database/migrations/2025_12_12_170500_add_rbac_role_definition_columns_to_tenants.php
new file mode 100644
index 0000000..71d77b2
--- /dev/null
+++ b/database/migrations/2025_12_12_170500_add_rbac_role_definition_columns_to_tenants.php
@@ -0,0 +1,23 @@
+string('rbac_role_definition_id')->nullable()->after('rbac_role_assignment_id');
+ $table->string('rbac_role_display_name')->nullable()->after('rbac_role_definition_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tenants', function (Blueprint $table) {
+ $table->dropColumn(['rbac_role_definition_id', 'rbac_role_display_name']);
+ });
+ }
+};
diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php
new file mode 100644
index 0000000..42effc5
--- /dev/null
+++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php
@@ -0,0 +1,35 @@
+@php
+ $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
+ $summary = $diff['summary'] ?? [];
+@endphp
+
+
+ Normalized diff
+
+ {{ $summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0) }}
+
+
+ @foreach (['added' => 'Added', 'removed' => 'Removed', 'changed' => 'Changed'] as $key => $label)
+ @php
+ $items = $diff[$key] ?? [];
+ @endphp
+
+ @if (! empty($items))
+
+ {{ $label }}
+
+ @foreach ($items as $name => $value)
+ -
+ {{ $name }}:
+ @if (is_array($value))
+
{{ json_encode($value, JSON_PRETTY_PRINT) }}
+ @else
+ {{ is_bool($value) ? ($value ? 'true' : 'false') : (string) $value }}
+ @endif
+
+ @endforeach
+
+
+ @endif
+ @endforeach
+
diff --git a/resources/views/filament/infolists/entries/normalized-settings.blade.php b/resources/views/filament/infolists/entries/normalized-settings.blade.php
new file mode 100644
index 0000000..3344125
--- /dev/null
+++ b/resources/views/filament/infolists/entries/normalized-settings.blade.php
@@ -0,0 +1,68 @@
+@php
+ $normalized = $getState() ?? [];
+ $warnings = $normalized['warnings'] ?? [];
+ $settings = $normalized['settings'] ?? [];
+@endphp
+
+
+ Normalized settings
+ @if (! empty($warnings))
+
+ Warnings
+
+ @foreach ($warnings as $warning)
+ - {{ $warning }}
+ @endforeach
+
+
+ @endif
+
+ @if (empty($settings))
+ No settings available.
+ @endif
+
+ @foreach ($settings as $block)
+
+ {{ $block['title'] ?? 'Settings' }}
+
+ @if (($block['type'] ?? 'keyValue') === 'table')
+
+
+
+
+ Path
+ Value
+
+
+
+ @foreach ($block['rows'] ?? [] as $row)
+
+
+ {{ $row['path'] ?? '-' }}
+ @if (! empty($row['label']))
+ {{ $row['label'] }}
+ @endif
+
+
+ {{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
+
+
+ @endforeach
+
+
+
+ @else
+
+ @foreach ($block['entries'] ?? [] as $entry)
+
+ - {{ $entry['key'] ?? '-' }}
+ -
+ {{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
+
+
+ @endforeach
+
+ @endif
+
+ @endforeach
+
diff --git a/resources/views/filament/infolists/entries/rbac-summary.blade.php b/resources/views/filament/infolists/entries/rbac-summary.blade.php
new file mode 100644
index 0000000..03a16e0
--- /dev/null
+++ b/resources/views/filament/infolists/entries/rbac-summary.blade.php
@@ -0,0 +1,72 @@
+@php
+ $tenant = $getRecord();
+ $warnings = $tenant->rbac_last_warnings ?? [];
+ $canaries = $tenant->rbac_canary_results ?? [];
+@endphp
+
+
+ Last RBAC Setup
+
+
+ - Role
+ -
+ {{ $tenant->rbac_role_display_name ?? $tenant->rbac_role_definition_id ?? 'n/a' }}
+ @if ($tenant->rbac_role_definition_id)
+ (ID: {{ $tenant->rbac_role_definition_id }})
+ @endif
+
+
+
+ - Scope
+ -
+ {{ $tenant->rbac_scope_mode ?? 'n/a' }}
+ @if ($tenant->rbac_scope_id)
+ ({{ $tenant->rbac_scope_id }})
+ @endif
+
+
+
+ - Group ID
+ - {{ $tenant->rbac_group_id ?? 'n/a' }}
+
+
+ - Role Assignment
+ - {{ $tenant->rbac_role_assignment_id ?? 'n/a' }}
+
+
+ - Executed at
+ - {{ optional($tenant->rbac_last_setup_at)->toDateTimeString() ?? 'n/a' }}
+
+
+ - Executed by (user id)
+ - {{ $tenant->rbac_last_setup_by ?? 'n/a' }}
+
+
+
+
+ Canaries
+ @if (empty($canaries))
+ No canary results recorded.
+ @else
+
+ @foreach ($canaries as $key => $status)
+ -
+ {{ $key }}:
+ {{ $status }}
+
+ @endforeach
+
+ @endif
+
+
+ @if (! empty($warnings))
+
+ Warnings
+
+ @foreach ($warnings as $warning)
+ - {{ $warning }}
+ @endforeach
+
+
+ @endif
+
diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php
new file mode 100644
index 0000000..2138c31
--- /dev/null
+++ b/resources/views/filament/infolists/entries/restore-preview.blade.php
@@ -0,0 +1,29 @@
+@php
+ $preview = $getState() ?? [];
+@endphp
+
+@if (empty($preview))
+ No preview available.
+@else
+
+ @foreach ($preview as $item)
+
+
+ {{ $item['policy_identifier'] ?? 'Policy' }}
+
+ {{ $item['action'] ?? 'action' }}
+
+
+
+ {{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }}
+
+
+ @if (! empty($item['validation_warning']))
+
+ {{ $item['validation_warning'] }}
+
+ @endif
+
+ @endforeach
+
+@endif
diff --git a/resources/views/filament/infolists/entries/snapshot-json.blade.php b/resources/views/filament/infolists/entries/snapshot-json.blade.php
new file mode 100644
index 0000000..8e7d3cf
--- /dev/null
+++ b/resources/views/filament/infolists/entries/snapshot-json.blade.php
@@ -0,0 +1,20 @@
+@php
+ $payload = $getState();
+ $json = is_string($payload) ? $payload : json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
+@endphp
+
+
+
+ Raw JSON
+
+
+
+ {{ $json }}
+
diff --git a/routes/web.php b/routes/web.php
index cf49536..98dc142 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,6 +1,7 @@
name('admin.consent.start');
+
+Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
+ ->name('admin.rbac.start');
+
+Route::get('/admin/rbac/callback', [RbacDelegatedAuthController::class, 'callback'])
+ ->name('admin.rbac.callback');
diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php
index aeb3cc2..642189a 100644
--- a/tests/Feature/Filament/BackupCreationTest.php
+++ b/tests/Feature/Filament/BackupCreationTest.php
@@ -33,6 +33,11 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
{
return new GraphResponse(true, []);
}
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
});
$tenant = Tenant::create([
diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php
new file mode 100644
index 0000000..9b436b5
--- /dev/null
+++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php
@@ -0,0 +1,54 @@
+ env('INTUNE_TENANT_ID', 'local-tenant'),
+ 'name' => 'Tenant One',
+ 'metadata' => [],
+ 'is_current' => true,
+ ]);
+
+ $tenant->makeCurrent();
+
+ $policy = Policy::create([
+ 'tenant_id' => $tenant->id,
+ 'external_id' => 'policy-1',
+ 'policy_type' => 'deviceConfiguration',
+ 'display_name' => 'Policy A',
+ 'platform' => 'windows',
+ ]);
+
+ $version = PolicyVersion::create([
+ 'tenant_id' => $tenant->id,
+ 'policy_id' => $policy->id,
+ 'version_number' => 1,
+ 'policy_type' => $policy->policy_type,
+ 'platform' => $policy->platform,
+ 'created_by' => 'tester@example.com',
+ 'captured_at' => CarbonImmutable::now(),
+ 'snapshot' => ['a', 'b'], // list-based snapshot should trigger warning
+ ]);
+
+ $user = User::factory()->create();
+
+ $policyResponse = $this->actingAs($user)
+ ->get(PolicyResource::getUrl('view', ['record' => $policy]));
+
+ $policyResponse->assertSee('This snapshot may be incomplete or malformed');
+
+ $versionResponse = $this->actingAs($user)
+ ->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
+
+ $versionResponse->assertSee('This snapshot may be incomplete or malformed');
+});
diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php
new file mode 100644
index 0000000..21d57f7
--- /dev/null
+++ b/tests/Feature/Filament/ODataTypeMismatchTest.php
@@ -0,0 +1,116 @@
+bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, ['payload' => []]);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+ });
+
+ $tenant = Tenant::create([
+ 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
+ 'name' => 'Tenant One',
+ 'metadata' => [],
+ 'is_current' => true,
+ ]);
+
+ $tenant->makeCurrent();
+
+ $policy = Policy::create([
+ 'tenant_id' => $tenant->id,
+ 'external_id' => 'policy-1',
+ 'policy_type' => 'deviceConfiguration',
+ 'display_name' => 'Policy A',
+ 'platform' => 'windows',
+ ]);
+
+ $snapshot = [
+ '@odata.type' => '#microsoft.graph.iosGeneralDeviceConfiguration',
+ 'displayName' => 'Policy A',
+ ];
+
+ PolicyVersion::create([
+ 'tenant_id' => $tenant->id,
+ 'policy_id' => $policy->id,
+ 'version_number' => 1,
+ 'policy_type' => $policy->policy_type,
+ 'platform' => $policy->platform,
+ 'created_by' => 'tester@example.com',
+ 'captured_at' => CarbonImmutable::now(),
+ 'snapshot' => $snapshot,
+ ]);
+
+ $backupSet = BackupSet::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Backup',
+ 'status' => 'completed',
+ 'item_count' => 1,
+ ]);
+
+ $backupItem = BackupItem::create([
+ 'tenant_id' => $tenant->id,
+ 'backup_set_id' => $backupSet->id,
+ 'policy_id' => $policy->id,
+ 'policy_identifier' => $policy->external_id,
+ 'policy_type' => $policy->policy_type,
+ 'platform' => $policy->platform,
+ 'payload' => $snapshot,
+ ]);
+
+ $user = User::factory()->create();
+
+ $detailResponse = $this->actingAs($user)
+ ->get(PolicyResource::getUrl('view', ['record' => $policy]));
+
+ $detailResponse->assertSee('@odata.type mismatch');
+
+ $service = app(RestoreService::class);
+ $run = $service->execute(
+ tenant: $tenant,
+ backupSet: $backupSet,
+ selectedItemIds: [$backupItem->id],
+ dryRun: false,
+ actorEmail: $user->email,
+ actorName: $user->name,
+ );
+
+ expect($run->status)->toBe('failed');
+ expect($run->results[0]['reason'])->toContain('mismatch');
+});
diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php
new file mode 100644
index 0000000..96e89cd
--- /dev/null
+++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php
@@ -0,0 +1,62 @@
+ env('INTUNE_TENANT_ID', 'local-tenant'),
+ 'name' => 'Tenant One',
+ 'metadata' => [],
+ 'is_current' => true,
+ ]);
+
+ putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
+ $tenant->makeCurrent();
+
+ $policy = Policy::create([
+ 'tenant_id' => $tenant->id,
+ 'external_id' => 'policy-1',
+ 'policy_type' => 'deviceConfiguration',
+ 'display_name' => 'Policy A',
+ 'platform' => 'windows',
+ ]);
+
+ PolicyVersion::create([
+ 'tenant_id' => $tenant->id,
+ 'policy_id' => $policy->id,
+ 'version_number' => 1,
+ 'policy_type' => $policy->policy_type,
+ 'platform' => $policy->platform,
+ 'created_by' => 'tester@example.com',
+ 'captured_at' => CarbonImmutable::now(),
+ 'snapshot' => [
+ '@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
+ 'omaSettings' => [
+ [
+ 'displayName' => 'Setting A',
+ 'omaUri' => './Vendor/MSFT/SettingA',
+ 'value' => 'Enabled',
+ ],
+ ],
+ ],
+ ]);
+
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)
+ ->get(PolicyResource::getUrl('view', ['record' => $policy]));
+
+ $response->assertOk();
+ $response->assertSee('Settings');
+ $response->assertSee('OMA-URI settings');
+ $response->assertSee('./Vendor/MSFT/SettingA');
+ $response->assertDontSee('@odata.type');
+});
diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php
new file mode 100644
index 0000000..2f25d7d
--- /dev/null
+++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php
@@ -0,0 +1,59 @@
+ env('INTUNE_TENANT_ID', 'local-tenant'),
+ 'name' => 'Tenant One',
+ 'metadata' => [],
+ 'is_current' => true,
+ ]);
+
+ putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
+ $tenant->makeCurrent();
+
+ $policy = Policy::create([
+ 'tenant_id' => $tenant->id,
+ 'external_id' => 'policy-1',
+ 'policy_type' => 'deviceConfiguration',
+ 'display_name' => 'Policy A',
+ 'platform' => 'windows',
+ ]);
+
+ $version = PolicyVersion::create([
+ 'tenant_id' => $tenant->id,
+ 'policy_id' => $policy->id,
+ 'version_number' => 1,
+ 'policy_type' => $policy->policy_type,
+ 'platform' => $policy->platform,
+ 'created_by' => 'tester@example.com',
+ 'captured_at' => CarbonImmutable::now(),
+ 'snapshot' => [
+ 'displayName' => 'Policy A',
+ 'settings' => [
+ ['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
+ ],
+ ],
+ ]);
+
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)
+ ->get(PolicyVersionResource::getUrl('view', ['record' => $version]));
+
+ $response->assertOk();
+ $response->assertSee('Raw JSON');
+ $response->assertSee('displayName');
+ $response->assertSee('Normalized settings');
+ $response->assertSee('Enable feature');
+ $response->assertSee('Normalized diff');
+});
diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php
index 4f8c9cf..a58d275 100644
--- a/tests/Feature/Filament/RestoreExecutionTest.php
+++ b/tests/Feature/Filament/RestoreExecutionTest.php
@@ -35,6 +35,11 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
{
return new GraphResponse(true, []);
}
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
});
$tenant = Tenant::create([
diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php
index c61652c..52cfc64 100644
--- a/tests/Feature/Filament/RestorePreviewTest.php
+++ b/tests/Feature/Filament/RestorePreviewTest.php
@@ -33,6 +33,11 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
{
return new GraphResponse(true, []);
}
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
});
$tenant = Tenant::create([
diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php
new file mode 100644
index 0000000..dd55e28
--- /dev/null
+++ b/tests/Feature/Filament/TenantRbacWizardTest.php
@@ -0,0 +1,613 @@
+set('tenantpilot.features.conditional_access', false);
+});
+
+function tenantWithApp(): Tenant
+{
+ return Tenant::create([
+ 'tenant_id' => 'tenant-guid',
+ 'name' => 'Tenant One',
+ 'app_client_id' => 'client-123',
+ 'app_client_secret' => 'secret',
+ 'status' => 'active',
+ ]);
+}
+
+test('rbac action prompts login when no delegated token', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ 'group_mode' => 'create',
+ ])
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ expect(Cache::get(RbacDelegatedAuthController::cacheKey($tenant, $user->id, session()->getId())))->toBeNull();
+});
+
+test('rbac action succeeds and clears token cache', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
+ Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
+
+ app()->bind(GraphClientInterface::class, function () {
+ return new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ $filter = $options['query']['$filter'] ?? '';
+
+ if ($method === 'GET' && $path === 'servicePrincipals') {
+ return new GraphResponse(true, ['value' => [['id' => 'sp-1']]]);
+ }
+
+ if ($method === 'GET' && $path === 'groups' && str_contains($filter, 'displayName eq')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'groups') {
+ return new GraphResponse(true, ['id' => 'group-1']);
+ }
+
+ if ($method === 'POST' && str_contains($path, '/members/$ref')) {
+ return new GraphResponse(true, []);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions') {
+ return new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['id' => 'assign-1']);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceConfigurations')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceCompliancePolicies')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ return new GraphResponse(true, ['value' => []]);
+ }
+ };
+ });
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ 'group_mode' => 'create',
+ ])
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $tenant->refresh();
+
+ expect($tenant->rbac_group_id)->toBe('group-1');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+ expect($tenant->rbac_canary_results)->toMatchArray([
+ 'deviceConfigurations' => 'ok',
+ 'deviceCompliancePolicies' => 'ok',
+ ]);
+ expect($tenant->rbac_last_warnings)->toContain('ca_canary_disabled');
+ expect(Cache::has($cacheKey))->toBeFalse();
+});
+
+test('rbac action is idempotent on rerun', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
+ Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
+
+ app()->bind(GraphClientInterface::class, function () {
+ return new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ if ($method === 'GET' && $path === 'servicePrincipals') {
+ return new GraphResponse(true, ['value' => [['id' => 'sp-1']]]);
+ }
+
+ if ($method === 'POST' && str_contains($path, '/members/$ref')) {
+ return new GraphResponse(false, [], 400, [
+ [
+ 'error' => [
+ 'code' => 'Request_BadRequest',
+ 'message' => 'One or more added object references already exist for the following modified objects',
+ ],
+ ],
+ ]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions') {
+ return new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['value' => [[
+ 'id' => 'assign-1',
+ 'members' => ['group-1'],
+ 'resourceScopes' => ['/'],
+ 'roleDefinition' => ['id' => 'role-1'],
+ ]]]);
+ }
+
+ if ($method === 'PATCH' && str_contains($path, 'deviceManagement/roleAssignments/assign-1')) {
+ return new GraphResponse(true, ['id' => 'assign-1']);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'groups/')) {
+ $id = str_replace('groups/', '', $path);
+
+ return new GraphResponse(true, ['id' => $id, 'displayName' => "Group {$id}"]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceConfigurations')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceCompliancePolicies')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ return new GraphResponse(true, ['value' => []]);
+ }
+ };
+ });
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'scope_group',
+ 'scope_group_id' => 'group-scope',
+ 'group_mode' => 'existing',
+ 'existing_group_id' => 'group-1',
+ ])
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $tenant->refresh();
+ expect($tenant->rbac_group_id)->toBe('group-1');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+ expect($tenant->rbac_scope_mode)->toBe('scope_group');
+ expect($tenant->rbac_last_warnings)->toContain('scope_limited');
+ expect($tenant->rbac_status)->toBe('ok');
+});
+
+test('existing group membership error from Graph json payload is treated idempotently', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
+ Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
+
+ app()->bind(GraphClientInterface::class, function () {
+ return new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ if ($method === 'GET' && $path === 'servicePrincipals') {
+ return new GraphResponse(true, ['value' => [['id' => 'sp-1']]]);
+ }
+
+ if ($method === 'GET' && $path === 'groups' && str_contains($options['query']['$filter'] ?? '', 'displayName')) {
+ return new GraphResponse(true, ['value' => [['id' => 'group-1', 'displayName' => 'Existing Group']]]);
+ }
+
+ if ($method === 'POST' && str_contains($path, '/members/$ref')) {
+ return new GraphResponse(false, [], 400, [
+ [
+ 'error' => [
+ 'code' => 'Request_BadRequest',
+ 'message' => 'One or more added object references already exist for the following modified objects',
+ ],
+ ],
+ ]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions') {
+ return new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['id' => 'assign-1']);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceConfigurations')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceCompliancePolicies')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ return new GraphResponse(true, ['value' => []]);
+ }
+ };
+ });
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ 'group_mode' => 'existing',
+ 'existing_group_id' => 'group-1',
+ ])
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $tenant->refresh();
+ expect($tenant->rbac_group_id)->toBe('group-1');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+ expect($tenant->rbac_status)->toBe('ok');
+});
+
+test('group picker is disabled without delegated token', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'group_mode' => 'existing',
+ ])
+ ->assertFormFieldDisabled('existing_group_id');
+
+ expect(\App\Filament\Resources\TenantResource::groupSearchHelper($tenant))->toBe('Login to search groups');
+});
+
+test('group picker toggles when switching modes', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'group_mode' => 'existing',
+ ])
+ ->assertFormFieldVisible('existing_group_id')
+ ->assertFormFieldHidden('group_name');
+});
+
+test('delegated group search returns options and persists selection', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
+ Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
+
+ app()->bind(GraphClientInterface::class, function () {
+ return new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ $filter = $options['query']['$filter'] ?? '';
+
+ if ($method === 'GET' && $path === 'servicePrincipals') {
+ return new GraphResponse(true, ['value' => [['id' => 'sp-1']]]);
+ }
+
+ if ($method === 'GET' && $path === 'groups' && str_contains($filter, 'securityEnabled eq true')) {
+ return new GraphResponse(true, ['value' => [
+ ['id' => 'group-123', 'displayName' => 'Ops Team'],
+ ['id' => 'group-456', 'displayName' => 'Helpdesk'],
+ ]]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'groups/')) {
+ $id = str_replace('groups/', '', $path);
+
+ return new GraphResponse(true, ['id' => $id, 'displayName' => 'Ops Team']);
+ }
+
+ if ($method === 'POST' && str_contains($path, '/members/$ref')) {
+ return new GraphResponse(true, []);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions') {
+ return new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['id' => 'assign-1']);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceConfigurations')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceCompliancePolicies')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ return new GraphResponse(true, ['value' => []]);
+ }
+ };
+ });
+
+ $options = \App\Filament\Resources\TenantResource::groupSearchOptions($tenant, 'Ops');
+
+ expect($options)->toHaveKey('group-123');
+ expect($options['group-123'])->toContain('Ops Team');
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'group_mode' => 'existing',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ 'existing_group_id' => 'group-123',
+ ])
+ ->assertFormFieldEnabled('existing_group_id')
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $tenant->refresh();
+
+ expect($tenant->rbac_group_id)->toBe('group-123');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+ expect(Cache::has($cacheKey))->toBeFalse();
+});
+
+test('delegated role search returns options and persists role definition id', function () {
+ $tenant = tenantWithApp();
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null);
+ Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5));
+
+ app()->bind(GraphClientInterface::class, function () {
+ return new class implements GraphClientInterface
+ {
+ public function listPolicies(string $policyType, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getOrganization(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function getServicePrincipalPermissions(array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ $filter = $options['query']['$filter'] ?? '';
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions') {
+ return new GraphResponse(true, ['value' => [
+ ['id' => 'role-1', 'displayName' => 'Policy and Profile Manager'],
+ ['id' => 'role-2', 'displayName' => 'Helpdesk Operator'],
+ ]]);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleDefinitions/role-1') {
+ return new GraphResponse(true, ['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']);
+ }
+
+ if ($method === 'GET' && $path === 'servicePrincipals') {
+ return new GraphResponse(true, ['value' => [['id' => 'sp-1']]]);
+ }
+
+ if ($method === 'GET' && $path === 'groups' && str_contains($filter, 'displayName eq')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'groups') {
+ return new GraphResponse(true, ['id' => 'group-1']);
+ }
+
+ if ($method === 'POST' && str_contains($path, '/members/$ref')) {
+ return new GraphResponse(true, []);
+ }
+
+ if ($method === 'GET' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'POST' && $path === 'deviceManagement/roleAssignments') {
+ return new GraphResponse(true, ['id' => 'assign-1']);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceConfigurations')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ if ($method === 'GET' && str_starts_with($path, 'deviceManagement/deviceCompliancePolicies')) {
+ return new GraphResponse(true, ['value' => []]);
+ }
+
+ return new GraphResponse(true, ['value' => []]);
+ }
+ };
+ });
+
+ $roles = \App\Filament\Resources\TenantResource::roleSearchOptions($tenant, 'Policy');
+
+ expect($roles)->toHaveKey('role-1');
+ expect($roles['role-1'])->toContain('Policy and Profile Manager');
+
+ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
+ ->mountAction('setup_rbac')
+ ->setActionData([
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ 'group_mode' => 'create',
+ ])
+ ->callMountedAction()
+ ->assertHasNoActionErrors();
+
+ $tenant->refresh();
+
+ expect($tenant->rbac_role_definition_id)->toBe('role-1');
+ expect($tenant->rbac_role_display_name)->toBe('Policy and Profile Manager');
+ expect(Cache::has($cacheKey))->toBeFalse();
+});
diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php
index 8804df8..d8b6de7 100644
--- a/tests/Feature/Filament/TenantSetupTest.php
+++ b/tests/Feature/Filament/TenantSetupTest.php
@@ -34,6 +34,11 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
{
return new GraphResponse(true, []);
}
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
});
$user = User::factory()->create();
@@ -94,6 +99,11 @@ public function applyPolicy(string $policyType, string $policyId, array $payload
{
return new GraphResponse(true, []);
}
+
+ public function request(string $method, string $path, array $options = []): GraphResponse
+ {
+ return new GraphResponse(true, []);
+ }
});
$user = User::factory()->create();
diff --git a/tests/Unit/MicrosoftGraphClientTest.php b/tests/Unit/MicrosoftGraphClientTest.php
new file mode 100644
index 0000000..bc3dd45
--- /dev/null
+++ b/tests/Unit/MicrosoftGraphClientTest.php
@@ -0,0 +1,46 @@
+set('graph.client_id', 'client-id');
+ config()->set('graph.client_secret', 'secret');
+ config()->set('graph.tenant_id', 'tenant-id');
+
+ Http::fake(function () {
+ $psrResponse = new PsrResponse(400, [], json_encode([
+ 'error' => [
+ 'code' => 'Request_BadRequest',
+ 'message' => 'One or more added object references already exist for the following modified properties: \'members\'.',
+ ],
+ ]));
+
+ $response = new HttpClientResponse($psrResponse);
+
+ throw new HttpRequestException($response);
+ });
+
+ $client = app(MicrosoftGraphClient::class);
+
+ $response = $client->request('POST', 'groups/group-1/members/$ref', [
+ 'json' => [
+ '@odata.id' => 'https://graph.microsoft.com/v1.0/directoryObjects/sp-1',
+ ],
+ 'access_token' => 'delegated-token',
+ ]);
+
+ expect($response)->toBeInstanceOf(GraphResponse::class);
+ expect($response->failed())->toBeTrue();
+ expect($response->status)->toBe(400);
+ expect($response->errors[0]['code'] ?? null)->toBe('Request_BadRequest');
+ expect(strtolower($response->errors[0]['message'] ?? ''))->toContain('added object references already exist');
+ expect($response->data['error']['message'] ?? null)->toContain('object references already exist');
+});
diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php
new file mode 100644
index 0000000..39cb52d
--- /dev/null
+++ b/tests/Unit/PolicyNormalizerTest.php
@@ -0,0 +1,65 @@
+normalizer = app(PolicyNormalizer::class);
+});
+
+it('normalizes oma uri settings', function () {
+ $snapshot = [
+ '@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
+ 'omaSettings' => [
+ [
+ 'displayName' => 'Setting A',
+ 'omaUri' => './Vendor/MSFT/SettingA',
+ 'value' => 'Enabled',
+ ],
+ ],
+ ];
+
+ $result = $this->normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
+
+ expect($result['status'])->toBe('success');
+ expect($result['warnings'])->toBe([]);
+ expect($result['settings'][0]['type'])->toBe('table');
+ expect($result['settings'][0]['rows'][0]['path'])->toBe('./Vendor/MSFT/SettingA');
+ expect($result['settings'][0]['rows'][0]['value'])->toBe('Enabled');
+});
+
+it('normalizes settings catalog structures', function () {
+ $snapshot = [
+ 'settings' => [
+ [
+ 'displayName' => 'Enable feature',
+ 'value' => ['value' => 'on'],
+ ],
+ ],
+ ];
+
+ $result = $this->normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
+
+ expect($result['settings'][0]['type'])->toBe('keyValue');
+ expect($result['settings'][0]['entries'][0]['key'])->toBe('Enable feature');
+ expect($result['settings'][0]['entries'][0]['value'])->toContain('on');
+});
+
+it('adds warning for malformed snapshots', function () {
+ $snapshot = ['only', 'values'];
+
+ $result = $this->normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
+
+ expect($result['status'])->toBe('warning');
+ expect($result['warnings'])->toContain('This snapshot may be incomplete or malformed');
+});
+
+it('detects @odata.type mismatch', function () {
+ $snapshot = [
+ '@odata.type' => '#microsoft.graph.iosGeneralDeviceConfiguration',
+ 'displayName' => 'Policy',
+ ];
+
+ $result = $this->normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
+
+ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch');
+});
diff --git a/tests/Unit/RbacOnboardingServiceTest.php b/tests/Unit/RbacOnboardingServiceTest.php
new file mode 100644
index 0000000..750ce5e
--- /dev/null
+++ b/tests/Unit/RbacOnboardingServiceTest.php
@@ -0,0 +1,468 @@
+set('tenantpilot.features.conditional_access', false);
+});
+
+function fakeTenant(): Tenant
+{
+ return Tenant::create([
+ 'tenant_id' => '00000000-0000-0000-0000-000000000000',
+ 'name' => 'Tenant One',
+ 'app_client_id' => 'app-client-123',
+ 'app_client_secret' => 'secret',
+ 'is_current' => true,
+ ]);
+}
+
+it('creates group membership and role assignment', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
+ ['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
+ ]]),
+ ['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['group_id'])->toBe('group-1');
+ expect($result['role_assignment_id'])->toBe('assign-1');
+ $tenant->refresh();
+ expect($tenant->rbac_group_id)->toBe('group-1');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+ expect($tenant->rbac_role_definition_id)->toBe('role-1');
+ expect($tenant->rbac_role_display_name)->toBe('Policy and Profile Manager');
+});
+
+it('is idempotent when group membership already exists', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(false, [], 400, [['message' => 'One or more added object references already exist']]),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
+ ['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
+ ]]),
+ ['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => new GraphResponse(true, ['value' => []]),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'existing',
+ 'existing_group_id' => 'group-1',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['group_id'])->toBe('group-1');
+});
+
+it('requires a role definition id', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+ $graph->shouldReceive('request')->never();
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('error');
+ expect(strtolower($result['message']))->toContain('roledefinitionid');
+});
+
+it('is idempotent when membership add returns a request-exception style message', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $message = 'HTTP request returned status code 400: {"error":{"code":"Request_BadRequest","message":"One or more added object references already exist for the following modified properties: \'members\'."}}';
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) use ($message) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(false, [], 400, [$message]),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
+ ['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
+ ]]),
+ ['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(true, ['id' => 'assign-1', 'members' => ['group-1'], 'resourceScopes' => ['/']]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => new GraphResponse(true, ['value' => []]),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'existing',
+ 'existing_group_id' => 'group-1',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['group_id'])->toBe('group-1');
+});
+
+it('is idempotent when group membership reference already exists', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $groupId = 'group-1';
+ $servicePrincipalId = 'sp-1';
+
+ $error = [
+ 'error' => [
+ 'code' => 'Request_BadRequest',
+ 'message' => 'One or more added object references already exist for the following modified properties: \'members\'.',
+ ],
+ ];
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) use ($groupId, $servicePrincipalId, $error) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => $servicePrincipalId]]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => $groupId]),
+ ['POST', "groups/{$groupId}/members/\$ref"] => new GraphResponse(false, $error, 400),
+ ['GET', 'deviceManagement/roleDefinitions'] => new GraphResponse(true, ['value' => [['id' => 'role-1', 'displayName' => 'Policy and Profile Manager']]]),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'identity/conditionalAccess/policies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['group_id'])->toBe('group-1');
+ expect($result['role_assignment_id'])->toBe('assign-1');
+ $tenant->refresh();
+ expect($tenant->rbac_group_id)->toBe('group-1');
+ expect($tenant->rbac_role_assignment_id)->toBe('assign-1');
+});
+
+it('surfaces membership errors with step, path, and status context', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturn(
+ new GraphResponse(true, ['value' => [['id' => 'sp-1']]]), // service principal
+ new GraphResponse(true, ['value' => []]), // existing group lookup
+ new GraphResponse(true, ['id' => 'group-1']), // create group
+ new GraphResponse(false, ['error' => ['code' => 'Request_BadRequest', 'message' => 'Different failure']], 400), // add member fails
+ );
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('error');
+ expect($result['message'])->toContain('step=ensureGroupMembership');
+ expect($result['message'])->toContain('path=/groups/group-1/members/$ref');
+ expect($result['message'])->toContain('status=400');
+});
+
+it('continues when role assignment member fetch fails and creates new assignment', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [
+ ['id' => 'assign-1', 'resourceScopes' => ['/'], 'roleDefinition' => ['id' => 'role-1']],
+ ]]),
+ ['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(false, [
+ 'error' => ['code' => 'BadRequest', 'message' => 'Unsupported $expand for members'],
+ ], 400),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-2']),
+ default => new GraphResponse(true, ['value' => []]),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['role_assignment_id'])->toBe('assign-2');
+});
+
+it('surfaces role assignment create errors with status and message', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(false, [
+ 'error' => ['code' => 'BadRequest', 'message' => 'Invalid members@odata.bind'],
+ ], 400),
+ default => new GraphResponse(true, ['value' => []]),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('error');
+ expect($result['message'])->toContain('step=createRoleAssignment');
+ expect($result['message'])->toContain('status=400');
+ expect($result['message'])->toContain('Invalid members@odata.bind');
+});
+
+it('handles unsupported AAD account API gracefully with manual setup instructions', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1', 'displayName' => 'TenantPilot-Intune-RBAC']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(false, [
+ 'error' => ['code' => 'BadRequest', 'message' => 'This API is not supported for AAD accounts (no addressUrl for Microsoft.Intune.Rbac,False).'],
+ ], 400, [], [], ['request_id' => 'req-123', 'client_request_id' => 'client-456']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => new GraphResponse(true, ['value' => []]),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ // Should be partial success, not complete failure
+ expect($result['status'])->toBe('manual_assignment_required');
+ expect($result['message'])->toContain('Intune RBAC API does not support automated role assignments');
+ expect($result['message'])->toContain('partially complete');
+ expect($result['message'])->toContain('TenantPilot-Intune-RBAC');
+ expect($result['message'])->toContain('group-1');
+ expect($result['message'])->toContain('request_id=req-123');
+ expect($result['message'])->toContain('client_request_id=client-456');
+ expect($result['group_id'])->toBe('group-1');
+ expect($result['role_assignment_id'])->toBeNull();
+ expect($result['warnings'])->toContain('manual_role_assignment_required');
+ expect($result['steps'])->toContain('role_assignment_manual_required');
+});
+
+it('lists role assignments without unsupported fields or expands', function () {
+ $tenant = fakeTenant();
+ $checked = false;
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path, array $options = []) use (&$checked) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => tap(new GraphResponse(true, ['value' => [[
+ 'id' => 'assign-1',
+ 'members' => ['group-1'],
+ 'resourceScopes' => ['/'],
+ 'roleDefinition' => ['id' => 'role-1', 'displayName' => 'Policy and Profile Manager'],
+ ]]]), function () use ($options, &$checked) {
+ expect($options['query']['$select'] ?? null)->toBe('id,displayName,resourceScopes,members');
+ expect($options['query']['$expand'] ?? null)->toBe('roleDefinition($select=id,displayName)');
+ $checked = true;
+ }),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-1']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($checked)->toBeTrue();
+ expect($result['status'])->toBe('ok');
+ expect($result['role_assignment_id'])->toBe('assign-1');
+});
+
+it('falls back when role assignment members are missing without crashing', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->andReturnUsing(function (string $method, string $path) {
+ return match ([$method, $path]) {
+ ['GET', 'servicePrincipals'] => new GraphResponse(true, ['value' => [['id' => 'sp-1']]]),
+ ['GET', 'groups'] => new GraphResponse(true, ['value' => []]),
+ ['POST', 'groups'] => new GraphResponse(true, ['id' => 'group-1']),
+ ['POST', 'groups/group-1/members/$ref'] => new GraphResponse(true, []),
+ ['GET', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['value' => [[
+ 'id' => 'assign-1',
+ 'resourceScopes' => ['/'],
+ 'roleDefinition' => ['id' => 'role-1'],
+ ]]]),
+ ['GET', 'deviceManagement/roleAssignments/assign-1'] => new GraphResponse(false, [], 400, [
+ ['error' => ['code' => 'BadRequest', 'message' => 'expand not allowed']],
+ ]),
+ ['POST', 'deviceManagement/roleAssignments'] => new GraphResponse(true, ['id' => 'assign-2']),
+ ['GET', 'deviceManagement/deviceConfigurations?$top=1'] => new GraphResponse(true, ['value' => []]),
+ ['GET', 'deviceManagement/deviceCompliancePolicies?$top=1'] => new GraphResponse(true, ['value' => []]),
+ default => throw new RuntimeException("Unexpected Graph request: {$method} {$path}"),
+ };
+ });
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('ok');
+ expect($result['role_assignment_id'])->toBe('assign-2');
+});
+
+it('fails when service principal is missing', function () {
+ $tenant = fakeTenant();
+
+ $graph = \Mockery::mock(GraphClientInterface::class);
+
+ $graph->shouldReceive('request')->once()->andReturn(
+ new GraphResponse(true, ['value' => []])
+ );
+
+ app()->instance(GraphClientInterface::class, $graph);
+
+ $service = app(RbacOnboardingService::class);
+
+ $result = $service->run($tenant, [
+ 'group_mode' => 'create',
+ 'role_definition_id' => 'role-1',
+ 'role_display_name' => 'Policy and Profile Manager',
+ 'scope' => 'all_devices',
+ ], null, 'access-token');
+
+ expect($result['status'])->toBe('error');
+ expect(strtolower($result['message']))->toContain('service principal');
+});