merge: resolve dev conflicts

This commit is contained in:
Ahmed Darrazi 2026-01-07 02:36:42 +01:00
commit 28c3f81521
266 changed files with 19758 additions and 533 deletions

View File

@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
## Active Technologies ## Active Technologies
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) - PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) - PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -24,6 +25,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15 - feat/005-bulk-operations: Added PHP 8.4.15

5
.gitignore vendored
View File

@ -13,6 +13,9 @@
/.zed /.zed
/auth.json /auth.json
/node_modules /node_modules
dist/
build/
coverage/
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
@ -23,3 +26,5 @@ Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/references /references
*.tmp
*.swp

View File

@ -1,50 +1,35 @@
# [PROJECT_NAME] Constitution # TenantPilot Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles ## Core Principles
### [PRINCIPLE_1_NAME] ### Safety-First Restore
<!-- Example: I. Library-First --> - Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary.
[PRINCIPLE_1_DESCRIPTION] - High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist.
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> - Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item.
### [PRINCIPLE_2_NAME] ### Auditability & Tenant Isolation
<!-- Example: II. CLI Interface --> - Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens).
[PRINCIPLE_2_DESCRIPTION] - Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant).
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME] ### Graph Abstraction & Contracts
<!-- Example: III. Test-First (NON-NEGOTIABLE) --> - All Microsoft Graph calls MUST go through `GraphClientInterface`.
[PRINCIPLE_3_DESCRIPTION] - Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code.
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> - Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`.
### [PRINCIPLE_4_NAME] ### Least Privilege
<!-- Example: IV. Integration Testing --> - Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
[PRINCIPLE_4_DESCRIPTION] - Never store secrets in code/config; never log credentials or tokens.
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME] ### Spec-First Workflow
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> - For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
[PRINCIPLE_5_DESCRIPTION] - New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME] ## Quality Gates
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> - Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
- Run `./vendor/bin/pint --dirty` before finalizing.
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance ## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> - This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
- Restore semantics changes require: spec update, checklist update, and tests proving safety.
[GOVERNANCE_RULES] **Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->

View File

@ -1,16 +1,16 @@
# Implementation Plan: TenantPilot v1 # Implementation Plan: TenantPilot v1
**Branch**: `tenantpilot-v1` **Branch**: `dev`
**Date**: 2025-12-12 **Date**: 2026-01-03
**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged) **Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven)
## Summary ## 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 (US7) and afterwards the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). TenantPilot v1 delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, Highlander enforcement, the delegated RBAC onboarding wizard (US7), and the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types).
## Status Snapshot (tasks.md is source of truth) ## 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 (US6), table ActionGroup UX, settings normalization/display (US1b), Dokploy/Sail runbooks. - **Done**: Phases 115 (US1US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops).
- **Next up**: **US7** Intune RBAC onboarding wizard (delegated, synchronous Filament flow). - **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant).
- **Upcoming**: **US8** Graph Contract Registry & Drift Guard (contract registry, type-family handling, verification command, fallback strategies). - **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`.
## Technical Baseline ## Technical Baseline
- Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL. - Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL.
@ -28,10 +28,12 @@ ## Completed Workstreams (no new action needed)
- **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants. - **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants.
- **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail. - **US6 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 snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section. - **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section.
- **US7 RBAC Wizard (Phase 14)**: Delegated, synchronous onboarding wizard with post-verify canary checks and audit trail.
- **US8 Graph Contracts & Drift Guard (Phase 15)**: Config-driven contract registry, type-family handling, capability downgrade fallbacks, and a drift-check command.
- **Housekeeping/UX (Phases 1012)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline. - **Housekeeping/UX (Phases 1012)**: 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. - **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured.
## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) ## Completed: US7 Intune RBAC Onboarding Wizard (Phase 14)
- Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default. - Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default.
- Scope alignment: FR-023FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache. - Scope alignment: FR-023FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache.
@ -56,7 +58,7 @@ ## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14)
- Health integration: Verify reflects RBAC status and prompts to run wizard when missing. - Health integration: Verify reflects RBAC status and prompts to run wizard when missing.
- Deployment/ops: no new env vars; ensure migrations for tenant RBAC columns are applied; run targeted tests `php artisan test tests/Unit/RbacOnboardingServiceTest.php tests/Feature/Filament/TenantRbacWizardTest.php`; Pint on touched files. - Deployment/ops: no new env vars; ensure migrations for tenant RBAC columns are applied; run targeted tests `php artisan test tests/Unit/RbacOnboardingServiceTest.php tests/Feature/Filament/TenantRbacWizardTest.php`; Pint on touched files.
## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) ## Completed: US8 Graph Contract Registry & Drift Guard (Phase 15)
- Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes. - Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes.
- Scope alignment: FR-031FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware). - Scope alignment: FR-031FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware).
@ -74,7 +76,7 @@ ## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15)
- Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types. - Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types.
## Testing & Quality Gates ## Testing & Quality Gates
- Continue using targeted Pest runs per change set; add/extend tests for US7 wizard now, and for US8 contracts when implemented. - Continue using targeted Pest runs per change set; add/extend tests when RBAC/contract behavior changes.
- Run Pint on touched files before finalizing. - Run Pint on touched files before finalizing.
- Maintain tenant isolation, audit logging, and restore safety gates; validate snapshot shape and type-family compatibility prior to restore execution. - Maintain tenant isolation, audit logging, and restore safety gates; validate snapshot shape and type-family compatibility prior to restore execution.
@ -83,6 +85,6 @@ ### Restore Safety Gate
- Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action. - Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action.
## Coordination ## Coordination
- Update `.specify/tasks.md` to reflect progress on US7 wizard and future US8 contract tasks; no new entities or scope changes introduced here. - Keep `.specify/tasks.md` and per-feature specs under `specs/` aligned with implementation changes.
- Stage validation required before production for any migration or restore-impacting change. - 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). - Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops).

View File

@ -1,20 +1,50 @@
# Feature Specification: TenantPilot v1 # Feature Specification: TenantPilot v1
**Feature Branch**: `tenantpilot-v1` **Feature Branch**: `dev`
**Created**: 2025-12-10 **Created**: 2025-12-10
**Status**: Draft **Status**: Active
**Last Updated**: 2026-01-03
**Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators. **Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators.
## Scope ## Scope
```yaml ```yaml
scope: scope:
description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und je nach Risikoklasse wiederherstellen können." description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und je nach Risikoklasse wiederherstellen können. Single Source of Truth: config/tenantpilot.php + config/graph_contracts.php."
supported_types: supported_types:
- key: deviceConfiguration - key: deviceConfiguration
name: "Device Configuration" name: "Device Configuration"
graph_resource: "deviceManagement/deviceConfigurations" graph_resource: "deviceManagement/deviceConfigurations"
notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog." filter: "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"
notes: "Standard Device Config inkl. Custom OMA-URI; excludes WUfB Update Rings."
- key: groupPolicyConfiguration
name: "Administrative Templates"
graph_resource: "deviceManagement/groupPolicyConfigurations"
notes: "Administrative Templates (Group Policy)."
- key: settingsCatalogPolicy
name: "Settings Catalog Policy"
graph_resource: "deviceManagement/configurationPolicies"
notes: "Settings Catalog policies; settings are hydrated from the /settings subresource."
- key: windowsUpdateRing
name: "Software Update Ring"
graph_resource: "deviceManagement/deviceConfigurations"
filter: "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"
notes: "Windows Update for Business (WUfB) update rings."
- key: windowsFeatureUpdateProfile
name: "Feature Updates (Windows)"
graph_resource: "deviceManagement/windowsFeatureUpdateProfiles"
- key: windowsQualityUpdateProfile
name: "Quality Updates (Windows)"
graph_resource: "deviceManagement/windowsQualityUpdateProfiles"
- key: windowsDriverUpdateProfile
name: "Driver Updates (Windows)"
graph_resource: "deviceManagement/windowsDriverUpdateProfiles"
- key: deviceCompliancePolicy - key: deviceCompliancePolicy
name: "Device Compliance" name: "Device Compliance"
@ -25,6 +55,16 @@ ## Scope
graph_resource: "deviceAppManagement/managedAppPolicies" graph_resource: "deviceAppManagement/managedAppPolicies"
notes: "iOS und Android Managed App Protection." notes: "iOS und Android Managed App Protection."
- key: mamAppConfiguration
name: "App Configuration (MAM)"
graph_resource: "deviceAppManagement/targetedManagedAppConfigurations"
notes: "App configuration targeting managed apps (MAM)."
- key: managedDeviceAppConfiguration
name: "App Configuration (Device)"
graph_resource: "deviceAppManagement/mobileAppConfigurations"
notes: "Managed device app configuration profiles."
- key: conditionalAccessPolicy - key: conditionalAccessPolicy
name: "Conditional Access" name: "Conditional Access"
graph_resource: "identity/conditionalAccess/policies" graph_resource: "identity/conditionalAccess/policies"
@ -35,6 +75,14 @@ ## Scope
graph_resource: "deviceManagement/deviceManagementScripts" graph_resource: "deviceManagement/deviceManagementScripts"
notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)." notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)."
- key: deviceShellScript
name: "macOS Shell Scripts"
graph_resource: "deviceManagement/deviceShellScripts"
- key: deviceHealthScript
name: "Proactive Remediations"
graph_resource: "deviceManagement/deviceHealthScripts"
- key: enrollmentRestriction - key: enrollmentRestriction
name: "Enrollment Restrictions" name: "Enrollment Restrictions"
graph_resource: "deviceManagement/deviceEnrollmentConfigurations" graph_resource: "deviceManagement/deviceEnrollmentConfigurations"
@ -46,22 +94,40 @@ ## Scope
- key: windowsEnrollmentStatusPage - key: windowsEnrollmentStatusPage
name: "Enrollment Status Page (ESP)" name: "Enrollment Status Page (ESP)"
graph_resource: "deviceManagement/deviceEnrollmentConfigurations" graph_resource: "deviceManagement/deviceEnrollmentConfigurations"
filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'" notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration."
- key: endpointSecurityIntent - key: endpointSecurityIntent
name: "Endpoint Security Intents" name: "Endpoint Security Intents"
graph_resource: "deviceManagement/intents" graph_resource: "deviceManagement/intents"
notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates." notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates."
- key: endpointSecurityPolicy
name: "Endpoint Security Policies"
graph_resource: "deviceManagement/configurationPolicies"
notes: "Configuration policies classified via technologies/templateReference; restore execution enabled with template validation (Feature 023)."
- key: securityBaselinePolicy
name: "Security Baselines"
graph_resource: "deviceManagement/configurationPolicies"
notes: "High risk; v1 restore stays preview-only."
- key: mobileApp - key: mobileApp
name: "Applications (Metadata only)" name: "Applications (Metadata only)"
graph_resource: "deviceAppManagement/mobileApps" graph_resource: "deviceAppManagement/mobileApps"
notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)." notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)."
- key: settingsCatalogPolicy foundation_types:
name: "Settings Catalog Policy" - key: assignmentFilter
graph_resource: "deviceManagement/configurationPolicies" name: "Assignment Filter"
notes: "Intune Settings Catalog Policies liegen NICHT unter deviceConfigurations, sondern unter configurationPolicies. v1 behandelt sie als eigenen Typ." graph_resource: "deviceManagement/assignmentFilters"
- key: roleScopeTag
name: "Scope Tag"
graph_resource: "deviceManagement/roleScopeTags"
- key: notificationMessageTemplate
name: "Notification Message Template"
graph_resource: "deviceManagement/notificationMessageTemplates"
restore_matrix: restore_matrix:
deviceConfiguration: deviceConfiguration:
@ -70,6 +136,37 @@ ## Scope
risk: medium risk: medium
notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht." notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht."
groupPolicyConfiguration:
backup: full
restore: enabled
risk: medium
settingsCatalogPolicy:
backup: full
restore: enabled
risk: medium
notes: "Settings are applied via configurationPolicies/{id}/settings; capability fallbacks may require manual follow-up."
windowsUpdateRing:
backup: full
restore: enabled
risk: medium-high
windowsFeatureUpdateProfile:
backup: full
restore: enabled
risk: high
windowsQualityUpdateProfile:
backup: full
restore: enabled
risk: high
windowsDriverUpdateProfile:
backup: full
restore: enabled
risk: high
deviceCompliancePolicy: deviceCompliancePolicy:
backup: full backup: full
restore: enabled restore: enabled
@ -82,6 +179,16 @@ ## Scope
risk: medium-high risk: medium-high
notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig." notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig."
mamAppConfiguration:
backup: full
restore: enabled
risk: medium-high
managedDeviceAppConfiguration:
backup: full
restore: enabled
risk: medium-high
conditionalAccessPolicy: conditionalAccessPolicy:
backup: full backup: full
restore: preview-only restore: preview-only
@ -94,6 +201,16 @@ ## Scope
risk: medium risk: medium
notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten." notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten."
deviceShellScript:
backup: full
restore: enabled
risk: medium
deviceHealthScript:
backup: full
restore: enabled
risk: medium
enrollmentRestriction: enrollmentRestriction:
backup: full backup: full
restore: preview-only restore: preview-only
@ -118,17 +235,38 @@ ## Scope
risk: high risk: high
notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig." notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig."
settingsCatalogPolicy: endpointSecurityPolicy:
backup: full backup: full
restore: enableds restore: enabled
risk: medium risk: high
notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar." notes: "Enabled with template validation (Feature 023)."
securityBaselinePolicy:
backup: full
restore: preview-only
risk: high
notes: "High risk; preview-only by default."
mobileApp: mobileApp:
backup: metadata-only backup: metadata-only
restore: enabled restore: enabled
risk: low-medium risk: low-medium
notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder." notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder."
assignmentFilter:
backup: full
restore: enabled
risk: low
roleScopeTag:
backup: full
restore: enabled
risk: low
notificationMessageTemplate:
backup: full
restore: enabled
risk: low
``` ```
## User Scenarios & Testing *(mandatory)* ## User Scenarios & Testing *(mandatory)*

View File

@ -8,9 +8,9 @@ # Tasks: TenantPilot v1
**Prerequisites**: plan.md (complete), spec.md (complete) **Prerequisites**: plan.md (complete), spec.md (complete)
**Status snapshot** **Status snapshot**
- Done: Phases 113 (US1US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops) - Done: Phases 115 (US1US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops)
- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous) - Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant)
- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard - Next up: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`
--- ---
@ -188,7 +188,7 @@ ## Acceptance Criteria
- Restore von `settingsCatalogPolicy` scheitert nicht mehr an `Platforms`. - Restore von `settingsCatalogPolicy` scheitert nicht mehr an `Platforms`.
- Results zeigen bei Fehlern weiterhin request-id/client-request-id (bleibt wie T177). - Results zeigen bei Fehlern weiterhin request-id/client-request-id (bleibt wie T177).
- [ ] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display - [x] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display
- **Goal:** Für `settingsCatalogPolicy` sollen die **Configuration settings** (wie im Intune Portal unter *Configuration settings*) im System sichtbar sein: - **Goal:** Für `settingsCatalogPolicy` sollen die **Configuration settings** (wie im Intune Portal unter *Configuration settings*) im System sichtbar sein:
- in **Policy Version Raw JSON** enthalten - in **Policy Version Raw JSON** enthalten
@ -278,7 +278,7 @@ ## Verification
- [ ] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot - [x] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot
- **Goal:** `settingsCatalogPolicy` soll die *Configuration settings* nicht nur in Backups, sondern auch in **Policy Versions** enthalten, damit **Policy Detail**, Diff/Preview/Restore auf den echten Settings basieren. - **Goal:** `settingsCatalogPolicy` soll die *Configuration settings* nicht nur in Backups, sondern auch in **Policy Versions** enthalten, damit **Policy Detail**, Diff/Preview/Restore auf den echten Settings basieren.
- **Why:** Aktuell hydriert nur `BackupService`, aber Policy Detail/Versions zeigen weiterhin nur Base-Metadaten. - **Why:** Aktuell hydriert nur `BackupService`, aber Policy Detail/Versions zeigen weiterhin nur Base-Metadaten.
@ -610,7 +610,7 @@ ## Acceptance Criteria
- [ ]T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) - [x] T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics)
- **Goal:** Settings Catalog Policies sollen im Policy/Version Detail **für Admins lesbar** sein, ohne dass wir “alle Settings kennen müssen”. - **Goal:** Settings Catalog Policies sollen im Policy/Version Detail **für Admins lesbar** sein, ohne dass wir “alle Settings kennen müssen”.
- Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte** - Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte**
@ -699,7 +699,7 @@ ## Acceptance Criteria
- **Readable Setting name** (not a cut-off vendor string) - **Readable Setting name** (not a cut-off vendor string)
- **Readable Value preview** (True/False/12/etc.) - **Readable Value preview** (True/False/12/etc.)
- [ ] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings - [x] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings
**Goal:** Restore für `settingsCatalogPolicy` soll Settings zuverlässig anwenden können, ohne ModelValidationFailure wegen fehlender/entfernter `@odata.type`. **Goal:** Restore für `settingsCatalogPolicy` soll Settings zuverlässig anwenden können, ohne ModelValidationFailure wegen fehlender/entfernter `@odata.type`.
@ -787,7 +787,7 @@ ### Implementation for User Story 4
- [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. - [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection.
- [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. - [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`.
- [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. - [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`.
- [ ] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. - [x] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id.
## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) ## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2)

View File

@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). - Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. - Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
### Troubleshooting
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
### Configuration ### Configuration
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.

View File

@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Console\Command;
class ReclassifyEnrollmentConfigurations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'intune:reclassify-enrollment-configurations {--tenant=} {--write : Write changes (default is dry-run)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reclassify enrollment configuration items (e.g. ESP) that were synced under the wrong policy type.';
/**
* Execute the console command.
*/
public function __construct(private readonly GraphClientInterface $graphClient)
{
parent::__construct();
}
public function handle(): int
{
$tenant = $this->resolveTenantOrNull();
$dryRun = ! (bool) $this->option('write');
$query = Policy::query()
->with(['tenant'])
->active()
->where('policy_type', 'enrollmentRestriction');
if ($tenant) {
$query->where('tenant_id', $tenant->id);
}
$candidates = $query->get();
$changedVersions = 0;
$changedPolicies = 0;
$ignoredPolicies = 0;
foreach ($candidates as $policy) {
$latestVersion = $policy->versions()->latest('version_number')->first();
$snapshot = $latestVersion?->snapshot;
if (! is_array($snapshot)) {
$snapshot = $this->fetchSnapshotOrNull($policy);
}
if (! is_array($snapshot)) {
continue;
}
if (! $this->isEspSnapshot($snapshot)) {
continue;
}
$this->line(sprintf(
'ESP detected: policy=%s tenant_id=%s external_id=%s',
(string) $policy->getKey(),
(string) $policy->tenant_id,
(string) $policy->external_id,
));
if ($dryRun) {
continue;
}
$existingTarget = Policy::query()
->where('tenant_id', $policy->tenant_id)
->where('external_id', $policy->external_id)
->where('policy_type', 'windowsEnrollmentStatusPage')
->first();
if ($existingTarget) {
$policy->forceFill(['ignored_at' => now()])->save();
$ignoredPolicies++;
continue;
}
$policy->forceFill([
'policy_type' => 'windowsEnrollmentStatusPage',
])->save();
$changedPolicies++;
$changedVersions += PolicyVersion::query()
->where('policy_id', $policy->id)
->where('policy_type', 'enrollmentRestriction')
->update(['policy_type' => 'windowsEnrollmentStatusPage']);
}
$this->info('Done.');
$this->info('PolicyVersions changed: '.$changedVersions);
$this->info('Policies changed: '.$changedPolicies);
$this->info('Policies ignored: '.$ignoredPolicies);
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
return Command::SUCCESS;
}
private function isEspSnapshot(array $snapshot): bool
{
$odataType = $snapshot['@odata.type'] ?? null;
$configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0)
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
}
private function fetchSnapshotOrNull(Policy $policy): ?array
{
$tenant = $policy->tenant;
if (! $tenant) {
return null;
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
]);
if ($response->failed()) {
return null;
}
$payload = $response->data['payload'] ?? null;
return is_array($payload) ? $payload : null;
}
private function resolveTenantOrNull(): ?Tenant
{
$tenantOption = $this->option('tenant');
if (! $tenantOption) {
return null;
}
return Tenant::query()
->forTenant($tenantOption)
->firstOrFail();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Services\BackupScheduling\BackupScheduleDispatcher;
use Illuminate\Console\Command;
class TenantpilotDispatchBackupSchedules extends Command
{
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
public function handle(BackupScheduleDispatcher $dispatcher): int
{
$tenantIdentifiers = (array) $this->option('tenant');
$result = $dispatcher->dispatchDue($tenantIdentifiers);
$this->info(sprintf(
'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).',
$result['scanned_schedules'],
$result['created_runs'],
$result['skipped_runs'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenantpilot:purge-nonpersistent
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
{--all : Purge for all tenants}
{--force : Actually delete rows}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
/**
* Execute the console command.
*/
public function handle(): int
{
$tenants = $this->resolveTenants();
if ($tenants->isEmpty()) {
$this->error('No tenants selected. Provide {tenant} or use --all.');
return self::FAILURE;
}
$isDryRun = ! (bool) $this->option('force');
if ($isDryRun) {
$this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.');
} else {
$this->warn('This will PERMANENTLY delete non-persistent tenant data.');
if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
foreach ($tenants as $tenant) {
$counts = $this->countsForTenant($tenant);
$this->line('');
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
$this->table(
['Table', 'Rows'],
collect($counts)
->map(fn (int $count, string $table) => [$table, $count])
->values()
->all(),
);
if ($isDryRun) {
continue;
}
DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query()
->where('tenant_id', $tenant->id)
->delete();
BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->delete();
AuditLog::query()
->where('tenant_id', $tenant->id)
->delete();
RestoreRun::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
BackupItem::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
BackupSet::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
PolicyVersion::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
Policy::query()
->where('tenant_id', $tenant->id)
->delete();
});
$this->info('Purged.');
}
return self::SUCCESS;
}
private function resolveTenants()
{
if ((bool) $this->option('all')) {
return Tenant::query()->get();
}
$tenantArg = $this->argument('tenant');
if ($tenantArg !== null && $tenantArg !== '') {
$tenant = Tenant::query()->forTenant($tenantArg)->first();
return $tenant ? collect([$tenant]) : collect();
}
try {
return collect([Tenant::current()]);
} catch (RuntimeException) {
return collect();
}
}
/**
* @return array<string,int>
*/
private function countsForTenant(Tenant $tenant): array
{
return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class InvalidPolicyTypeException extends RuntimeException
{
public array $unknownPolicyTypes;
public function __construct(array $unknownPolicyTypes)
{
$this->unknownPolicyTypes = array_values($unknownPolicyTypes);
parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes));
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Filament\Pages\Tenancy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
class RegisterTenant extends BaseRegisterTenant
{
public static function getLabel(): string
{
return 'Register tenant';
}
public static function canView(): bool
{
return true;
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
]);
}
/**
* @param array<string, mixed> $data
*/
protected function handleRegistration(array $data): Model
{
$tenant = Tenant::create($data);
$user = auth()->user();
if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => TenantRole::Owner->value],
]);
}
return $tenant;
}
}

View File

@ -0,0 +1,856 @@
<?php
namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class BackupScheduleResource extends Resource
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
protected static function currentTenantRole(): ?TenantRole
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
return $user->tenantRole(Tenant::current());
}
public static function canViewAny(): bool
{
return static::currentTenantRole() !== null;
}
public static function canView(Model $record): bool
{
return static::currentTenantRole() !== null;
}
public static function canCreate(): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canEdit(Model $record): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canDelete(Model $record): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function canDeleteAny(): bool
{
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->label('Schedule Name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('timezone')
->label('Timezone')
->options(static::timezoneOptions())
->searchable()
->default('UTC')
->required(),
Select::make('frequency')
->label('Frequency')
->options([
'daily' => 'Daily',
'weekly' => 'Weekly',
])
->default('daily')
->required()
->reactive(),
TextInput::make('time_of_day')
->label('Time of day')
->type('time')
->required()
->extraInputAttributes(['step' => 60]),
CheckboxList::make('days_of_week')
->label('Days of the week')
->options(static::dayOfWeekOptions())
->columns(2)
->visible(fn (Get $get): bool => $get('frequency') === 'weekly')
->required(fn (Get $get): bool => $get('frequency') === 'weekly')
->rules(['array', 'min:1']),
CheckboxList::make('policy_types')
->label('Policy types')
->options(static::policyTypeOptions())
->columns(2)
->required()
->helperText('Select the Microsoft Graph policy types that should be included in each run.')
->rules([
'array',
'min:1',
new SupportedPolicyTypesRule,
])
->columnSpanFull(),
Toggle::make('include_foundations')
->label('Include foundation types')
->default(true),
TextInput::make('retention_keep_last')
->label('Retention (keep last N Backup Sets)')
->type('number')
->default(30)
->minValue(1)
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('next_run_at', 'asc')
->columns([
IconColumn::make('is_enabled')
->label('Enabled')
->boolean()
->alignCenter(),
TextColumn::make('name')
->searchable()
->label('Schedule'),
TextColumn::make('frequency')
->label('Frequency')
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
'daily' => 'Daily',
'weekly' => 'Weekly',
default => (string) $state,
})
->color(fn (?string $state): string => match ($state) {
'daily' => 'success',
'weekly' => 'warning',
default => 'gray',
}),
TextColumn::make('time_of_day')
->label('Time')
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
TextColumn::make('timezone')
->label('Timezone'),
TextColumn::make('policy_types')
->label('Policy types')
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
TextColumn::make('retention_keep_last')
->label('Retention')
->suffix(' sets'),
TextColumn::make('last_run_status')
->label('Last run status')
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_RUNNING => 'Running',
BackupScheduleRun::STATUS_SUCCESS => 'Success',
BackupScheduleRun::STATUS_PARTIAL => 'Partial',
BackupScheduleRun::STATUS_FAILED => 'Failed',
BackupScheduleRun::STATUS_CANCELED => 'Canceled',
BackupScheduleRun::STATUS_SKIPPED => 'Skipped',
default => $state ? Str::headline($state) : '—',
})
->color(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_SUCCESS => 'success',
BackupScheduleRun::STATUS_PARTIAL => 'warning',
BackupScheduleRun::STATUS_RUNNING => 'primary',
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED,
BackupScheduleRun::STATUS_CANCELED => 'danger',
default => 'gray',
}),
TextColumn::make('last_run_at')
->label('Last run')
->dateTime()
->sortable(),
TextColumn::make('next_run_at')
->label('Next run')
->getStateUsing(function (BackupSchedule $record): ?string {
$nextRun = $record->next_run_at;
if (! $nextRun) {
return null;
}
$timezone = $record->timezone ?: 'UTC';
try {
return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s');
} catch (\Throwable) {
return $nextRun->format('M j, Y H:i:s');
}
})
->sortable(),
])
->filters([
SelectFilter::make('enabled_state')
->label('Enabled')
->options([
'enabled' => 'Enabled',
'disabled' => 'Disabled',
])
->query(function (Builder $query, array $data): void {
$value = $data['value'] ?? null;
if (blank($value)) {
return;
}
if ($value === 'enabled') {
$query->where('is_enabled', true);
return;
}
if ($value === 'disabled') {
$query->where('is_enabled', false);
}
}),
])
->actions([
ActionGroup::make([
Action::make('runNow')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (BackupSchedule $record): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->send();
return;
}
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'run_now',
],
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'run',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$notification = Notification::make()
->title('Run dispatched')
->body('The backup run has been queued.')
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
$notification->send();
}),
Action::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (BackupSchedule $record): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->send();
return;
}
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'retry',
],
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'retry',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$notification = Notification::make()
->title('Retry dispatched')
->body('A new backup run has been queued.')
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
$notification->send();
}),
EditAction::make()
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
DeleteAction::make()
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_run_now')
->label('Run now')
->icon('heroicon-o-play')
->color('success')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (Collection $records): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'run',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
continue;
}
$createdRunIds[] = (int) $run->id;
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now',
'bulk_run_id' => $bulkRun?->id,
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
}
$notification = Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
$notification->send();
}),
BulkAction::make('bulk_retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
->action(function (Collection $records): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
if ($records->isEmpty()) {
return;
}
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'retry',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
continue;
}
$createdRunIds[] = (int) $run->id;
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry',
'bulk_run_id' => $bulkRun?->id,
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
}
$notification = Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
$notification->send();
}),
DeleteBulkAction::make('bulk_delete')
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function getRelations(): array
{
return [
BackupScheduleRunsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBackupSchedules::route('/'),
'create' => Pages\CreateBackupSchedule::route('/create'),
'edit' => Pages\EditBackupSchedule::route('/{record}/edit'),
];
}
public static function policyTypesFullLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
return $labels === [] ? 'None' : implode(', ', $labels);
}
public static function policyTypesPreviewLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
if ($labels === []) {
return 'None';
}
$preview = array_slice($labels, 0, 2);
$remaining = count($labels) - count($preview);
$label = implode(', ', $preview);
if ($remaining > 0) {
$label .= sprintf(' +%d more', $remaining);
}
return $label;
}
/**
* @return array<int, string>
*/
private static function policyTypesLabels(BackupSchedule $record): array
{
$state = $record->policy_types;
if (is_string($state)) {
$decoded = json_decode($state, true);
if (is_array($decoded)) {
$state = $decoded;
}
}
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
$state = $state->toArray();
}
if (blank($state) || (! is_array($state))) {
return [];
}
$types = array_is_list($state)
? $state
: array_keys(array_filter($state));
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
if ($types === []) {
return [];
}
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy): array => [
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
])
->filter(fn (string $label, string $type): bool => $type !== '')
->all();
return array_map(
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
$types,
);
}
public static function ensurePolicyTypes(array $data): array
{
$types = array_values((array) ($data['policy_types'] ?? []));
try {
app(PolicyTypeResolver::class)->ensureSupported($types);
} catch (InvalidPolicyTypeException $exception) {
throw ValidationException::withMessages([
'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))],
]);
}
$data['policy_types'] = $types;
return $data;
}
public static function assignTenant(array $data): array
{
$data['tenant_id'] = Tenant::current()->getKey();
return $data;
}
public static function hydrateNextRun(array $data): array
{
if (! empty($data['time_of_day'])) {
$data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']);
}
$schedule = new BackupSchedule;
$schedule->forceFill([
'frequency' => $data['frequency'] ?? 'daily',
'time_of_day' => $data['time_of_day'] ?? '00:00:00',
'timezone' => $data['timezone'] ?? 'UTC',
'days_of_week' => (array) ($data['days_of_week'] ?? []),
]);
$nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule);
$data['next_run_at'] = $nextRun?->toDateTimeString();
return $data;
}
public static function normalizeTimeOfDay(string $time): string
{
if (preg_match('/^\d{2}:\d{2}$/', $time)) {
return $time.':00';
}
return $time;
}
protected static function timezoneOptions(): array
{
$zones = DateTimeZone::listIdentifiers();
sort($zones);
return array_combine($zones, $zones);
}
protected static function policyTypeOptions(): array
{
return static::policyTypeLabelMap();
}
protected static function policyTypeLabels(array $types): array
{
$map = static::policyTypeLabelMap();
return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types);
}
protected static function policyTypeLabelMap(): array
{
return collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy) => [
$policy['type'] => $policy['label'] ?? Str::headline($policy['type']),
])
->all();
}
protected static function dayOfWeekOptions(): array
{
return [
1 => 'Monday',
2 => 'Tuesday',
3 => 'Wednesday',
4 => 'Thursday',
5 => 'Friday',
6 => 'Saturday',
7 => 'Sunday',
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBackupSchedule extends CreateRecord
{
protected static string $resource = BackupScheduleResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data = BackupScheduleResource::ensurePolicyTypes($data);
$data = BackupScheduleResource::assignTenant($data);
return BackupScheduleResource::hydrateNextRun($data);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord;
class EditBackupSchedule extends EditRecord
{
protected static string $resource = BackupScheduleResource::class;
protected function mutateFormDataBeforeSave(array $data): array
{
$data = BackupScheduleResource::ensurePolicyTypes($data);
return BackupScheduleResource::hydrateNextRun($data);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBackupSchedules extends ListRecords
{
protected static string $resource = BackupScheduleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleRunsRelationManager extends RelationManager
{
protected static string $relationship = 'runs';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
->label('Scheduled for')
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (?string $state): string => match ($state) {
BackupScheduleRun::STATUS_SUCCESS => 'success',
BackupScheduleRun::STATUS_PARTIAL => 'warning',
BackupScheduleRun::STATUS_RUNNING => 'primary',
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED,
BackupScheduleRun::STATUS_CANCELED => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('duration')
->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string {
if (! $record->started_at || ! $record->finished_at) {
return '—';
}
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
if ($seconds < 60) {
return $seconds.'s';
}
$minutes = intdiv($seconds, 60);
$rem = $seconds % 60;
return sprintf('%dm %ds', $minutes, $rem);
}),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (BackupScheduleRun $record): string {
$summary = is_array($record->summary) ? $record->summary : [];
$total = (int) ($summary['policies_total'] ?? 0);
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
$errors = (int) ($summary['errors_count'] ?? 0);
if ($total === 0 && $backedUp === 0 && $errors === 0) {
return '—';
}
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
}),
Tables\Columns\TextColumn::make('error_code')
->label('Error')
->badge()
->default('—'),
Tables\Columns\TextColumn::make('error_message')
->label('Message')
->default('—')
->limit(80)
->wrap(),
Tables\Columns\TextColumn::make('backup_set_id')
->label('Backup set')
->default('—')
->url(function (BackupScheduleRun $record): ?string {
if (! $record->backup_set_id) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
})
->openUrlInNewTab(true),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->modalHeading('View backup schedule run')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (BackupScheduleRun $record): View {
return view('filament.modals.backup-schedule-run-view', [
'run' => $record,
]);
}),
])
->bulkActions([]);
}
}

View File

@ -4,17 +4,15 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use Filament\Actions; use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class BackupItemsRelationManager extends RelationManager class BackupItemsRelationManager extends RelationManager
{ {
@ -99,113 +97,110 @@ public function table(Table $table): Table
Actions\Action::make('addPolicies') Actions\Action::make('addPolicies')
->label('Add Policies') ->label('Add Policies')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
->form([ ->modalHeading('Add Policies')
Forms\Components\Select::make('policy_ids') ->modalSubmitAction(false)
->label('Policies') ->modalCancelActionLabel('Close')
->multiple() ->modalContent(function (): View {
->required()
->searchable()
->options(function (RelationManager $livewire) {
$backupSet = $livewire->getOwnerRecord();
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$existing = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];
return Policy::query()
->where('tenant_id', $tenantId)
->whereNull('ignored_at')
->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround)
->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing))
->orderBy('display_name')
->pluck('display_name', 'id');
}),
Forms\Components\Checkbox::make('include_assignments')
->label('Include assignments')
->default(true)
->helperText('Captures assignment include/exclude targeting and filters.'),
Forms\Components\Checkbox::make('include_scope_tags')
->label('Include scope tags')
->default(true)
->helperText('Captures policy scope tag IDs.'),
Forms\Components\Checkbox::make('include_foundations')
->label('Include foundations')
->default(true)
->helperText('Captures assignment filters, scope tags, and notification templates.'),
])
->action(function (array $data, BackupService $service) {
if (empty($data['policy_ids'])) {
Notification::make()
->title('No policies selected')
->warning()
->send();
return;
}
$backupSet = $this->getOwnerRecord(); $backupSet = $this->getOwnerRecord();
$tenant = $backupSet?->tenant ?? Tenant::current();
$service->addPoliciesToSet( return view('filament.modals.backup-set-policy-picker', [
tenant: $tenant, 'backupSetId' => $backupSet->getKey(),
backupSet: $backupSet, ]);
policyIds: $data['policy_ids'],
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
includeFoundations: $data['include_foundations'] ?? false,
);
$notificationTitle = ($data['include_foundations'] ?? false)
? 'Backup items added'
: 'Policies added to backup';
Notification::make()
->title($notificationTitle)
->success()
->send();
}), }),
]) ])
->actions([ ->actions([
Actions\ViewAction::make() Actions\ActionGroup::make([
->label('View policy') Actions\ViewAction::make()
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) ->label('View policy')
->hidden(fn ($record) => ! $record->policy_id) ->url(function (BackupItem $record): ?string {
->openUrlInNewTab(true), if (! $record->policy_id) {
Actions\Action::make('remove') return null;
->label('Remove') }
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record, AuditLogger $auditLogger) {
$record->delete();
if ($record->backupSet) { $tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
$record->backupSet->update([
'item_count' => $record->backupSet->items()->count(),
]);
}
if ($record->tenant) { return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
$auditLogger->log( })
tenant: $record->tenant, ->hidden(fn (BackupItem $record) => ! $record->policy_id)
action: 'backup.item_removed', ->openUrlInNewTab(true),
resourceType: 'backup_set', Actions\Action::make('remove')
resourceId: (string) $record->backup_set_id, ->label('Remove')
status: 'success', ->color('danger')
context: ['metadata' => ['policy_id' => $record->policy_id]] ->icon('heroicon-o-x-mark')
); ->requiresConfirmation()
} ->action(function (BackupItem $record, AuditLogger $auditLogger) {
$record->delete();
Notification::make() if ($record->backupSet) {
->title('Policy removed from backup') $record->backupSet->update([
->success() 'item_count' => $record->backupSet->items()->count(),
->send(); ]);
}), }
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.item_removed',
resourceType: 'backup_set',
resourceId: (string) $record->backup_set_id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id]]
);
}
Notification::make()
->title('Policy removed from backup')
->success()
->send();
}),
])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([]); ->bulkActions([
Actions\BulkActionGroup::make([
Actions\BulkAction::make('bulk_remove')
->label('Remove selected')
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->action(function (Collection $records, AuditLogger $auditLogger) {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$records->each(fn (BackupItem $record) => $record->delete());
$backupSet->update([
'item_count' => $backupSet->items()->count(),
]);
$tenant = $records->first()?->tenant;
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->id,
status: 'success',
context: [
'metadata' => [
'removed_count' => $records->count(),
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
],
]
);
}
Notification::make()
->title('Policies removed from backup')
->success()
->send();
}),
]),
]);
} }
/** /**

View File

@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('external_id')->label('External ID'), TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'), TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
TextEntry::make('created_at')->since(), TextEntry::make('created_at')->since(),
TextEntry::make('latest_snapshot_mode')
->label('Snapshot')
->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record);
if (($meta['source'] ?? null) !== 'metadata_only') {
return null;
}
$status = $meta['original_status'] ?? null;
return sprintf(
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
$status ?? 'an error'
);
})
->visible(fn (Policy $record) => $record->versions()->exists()),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array
return []; return [];
} }
private static function latestVersionMetadata(Policy $record): array
{
$metadata = $record->relationLoaded('versions')
? $record->versions->first()?->metadata
: $record->versions()->orderByDesc('captured_at')->value('metadata');
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
$metadata = $decoded ?? [];
}
return is_array($metadata) ? $metadata : [];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -623,6 +657,7 @@ private static function normalizedPolicyState(Policy $record): array
$normalized['context'] = 'policy'; $normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey(); $normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
$request->attributes->set($cacheKey, $normalized); $request->attributes->set($cacheKey, $normalized);
@ -763,7 +798,7 @@ private static function settingsTabState(Policy $record): array
$rows = $normalized['settings_table']['rows'] ?? []; $rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== []; $hasSettingsTable = is_array($rows) && $rows !== [];
if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized); $split = static::splitGeneralBlock($normalized);
return $split['normalized']; return $split['normalized'];

View File

@ -28,11 +28,35 @@ protected function getHeaderActions(): array
/** @var PolicySyncService $service */ /** @var PolicySyncService $service */
$service = app(PolicySyncService::class); $service = app(PolicySyncService::class);
$synced = $service->syncPolicies($tenant); $result = $service->syncPoliciesWithReport($tenant);
$syncedCount = count($result['synced'] ?? []);
$failureCount = count($result['failures'] ?? []);
$body = $syncedCount.' policies synced';
if ($failureCount > 0) {
$first = $result['failures'][0] ?? [];
$firstType = $first['policy_type'] ?? 'unknown';
$firstStatus = $first['status'] ?? null;
$firstErrorMessage = null;
$firstErrors = $first['errors'] ?? null;
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
}
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$suffix .= ' - '.trim($firstErrorMessage);
}
$body .= " ({$failureCount} failed; {$suffix})";
}
Notification::make() Notification::make()
->title('Policy sync completed') ->title('Policy sync completed')
->body(count($synced).' policies synced') ->body($body)
->success() ->success()
->sendToDatabase(auth()->user()) ->sendToDatabase(auth()->user())
->send(); ->send();

View File

@ -49,7 +49,7 @@ protected function getActions(): array
return; return;
} }
app(VersionService::class)->captureFromGraph( $version = app(VersionService::class)->captureFromGraph(
tenant: $tenant, tenant: $tenant,
policy: $policy, policy: $policy,
createdBy: auth()->user()?->email ?? null, createdBy: auth()->user()?->email ?? null,
@ -57,10 +57,23 @@ protected function getActions(): array
includeScopeTags: $data['include_scope_tags'] ?? false, includeScopeTags: $data['include_scope_tags'] ?? false,
); );
Notification::make() if (($version->metadata['source'] ?? null) === 'metadata_only') {
->title('Snapshot captured successfully.') $status = $version->metadata['original_status'] ?? null;
->success()
->send(); Notification::make()
->title('Snapshot captured (metadata only)')
->body(sprintf(
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
$status ?? 'an error'
))
->warning()
->send();
} else {
Notification::make()
->title('Snapshot captured successfully.')
->success()
->send();
}
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()])); $this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@ -34,6 +34,8 @@ public function table(Table $table): Table
->label('Restore to Intune') ->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square') ->icon('heroicon-o-arrow-path-rounded-square')
->color('danger') ->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.') ->modalSubheading('Creates a restore run using this policy version snapshot.')

View File

@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema
return $normalized; return $normalized;
}) })
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), ->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
Infolists\Components\ViewEntry::make('normalized_settings_standard') Infolists\Components\ViewEntry::make('normalized_settings_standard')
->view('filament.infolists.entries.policy-settings-standard') ->view('filament.infolists.entries.policy-settings-standard')
@ -87,10 +87,11 @@ public static function infolist(Schema $schema): Schema
$normalized['context'] = 'version'; $normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey(); $normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
return $normalized; return $normalized;
}) })
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), ->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
]), ]),
Tab::make('Raw JSON') Tab::make('Raw JSON')
->id('raw-json') ->id('raw-json')
@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema
: []; : [];
$to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform);
return $diff->compare($from, $to); $result = $diff->compare($from, $to);
$result['policy_type'] = $record->policy_type;
return $result;
}), }),
Infolists\Components\ViewEntry::make('diff_json') Infolists\Components\ViewEntry::make('diff_json')
->label('Raw diff (advanced)') ->label('Raw diff (advanced)')
@ -182,14 +186,14 @@ public static function table(Table $table): Table
->falseLabel('Archived'), ->falseLabel('Archived'),
]) ])
->actions([ ->actions([
Actions\ViewAction::make() Actions\ViewAction::make(),
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard') Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard') ->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square') ->icon('heroicon-o-arrow-path-rounded-square')
->color('primary') ->color('primary')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')

View File

@ -4,13 +4,18 @@
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService; use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use App\Support\TenantRole;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -23,6 +28,8 @@
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -33,6 +40,8 @@ class TenantResource extends Resource
{ {
protected static ?string $model = Tenant::class; protected static ?string $model = Tenant::class;
protected static bool $isScopedToTenant = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
@ -44,6 +53,15 @@ public static function form(Schema $schema): Schema
Forms\Components\TextInput::make('name') Forms\Components\TextInput::make('name')
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id') Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)') ->label('Tenant ID (GUID)')
->required() ->required()
@ -69,10 +87,28 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function getEloquentQuery(): Builder
{
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->pluck('tenants.id');
return parent::getEloquentQuery()
->withTrashed()
->whereIn('id', $tenantIds)
->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed())
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')
->searchable(), ->searchable(),
@ -80,6 +116,23 @@ public static function table(Table $table): Table
->label('Tenant ID') ->label('Tenant ID')
->copyable() ->copyable()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('environment')
->badge()
->color(fn (?string $state) => match ($state) {
'prod' => 'danger',
'dev' => 'warning',
'staging' => 'info',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('policies_count')
->label('Policies')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('last_policy_sync_at')
->label('Last Sync')
->since()
->sortable(),
Tables\Columns\TextColumn::make('domain') Tables\Columns\TextColumn::make('domain')
->copyable() ->copyable()
->toggleable(), ->toggleable(),
@ -102,6 +155,13 @@ public static function table(Table $table): Table
->trueLabel('All') ->trueLabel('All')
->falseLabel('Archived') ->falseLabel('Archived')
->default(true), ->default(true),
Tables\Filters\SelectFilter::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('app_status') Tables\Filters\SelectFilter::make('app_status')
->options([ ->options([
'ok' => 'OK', 'ok' => 'OK',
@ -113,6 +173,51 @@ public static function table(Table $table): Table
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
ActionGroup::make([ ActionGroup::make([
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canSyncTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger): void {
SyncPoliciesJob::dispatch($record->getKey());
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
Notification::make()
->title('Sync started')
->body("Sync dispatched for {$record->name}.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->sendToDatabase(auth()->user())
->send();
}),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
Actions\EditAction::make(), Actions\EditAction::make(),
Actions\RestoreAction::make() Actions\RestoreAction::make()
->label('Restore') ->label('Restore')
@ -157,6 +262,12 @@ public static function table(Table $table): Table
->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(), ->openUrlInNewTab(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => static::entraUrl($record))
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
->openUrlInNewTab(),
Actions\Action::make('verify') Actions\Action::make('verify')
->label('Verify configuration') ->label('Verify configuration')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
@ -236,7 +347,106 @@ public static function table(Table $table): Table
}), }),
])->icon('heroicon-o-ellipsis-vertical'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([]) ->bulkActions([
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->authorize(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant));
if ($eligible->isEmpty()) {
Notification::make()
->title('Bulk sync skipped')
->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle')
->info()
->sendToDatabase($user)
->send();
return;
}
$tenantContext = Tenant::current() ?? $eligible->first();
if (! $tenantContext) {
return;
}
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
foreach ($eligible as $tenant) {
SyncPoliciesJob::dispatch($tenant->getKey());
$auditLogger->log(
tenant: $tenant,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]],
);
}
$count = $eligible->count();
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->duration(8000)
->sendToDatabase($user)
->send();
BulkTenantSyncJob::dispatch($run->id);
})
->deselectRecordsAfterCompletion(),
])
->headerActions([]); ->headerActions([]);
} }
@ -434,7 +644,10 @@ public static function rbacAction(): Actions\Action
->label('Open RBAC login') ->label('Open RBAC login')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(), 'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $record), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $record->external_id,
'record' => $record,
]),
])), ])),
]) ])
->warning() ->warning()
@ -573,7 +786,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
->label('Login to load roles') ->label('Login to load roles')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(), 'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $tenant), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
])); ]));
} }
@ -755,7 +971,10 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
->label('Login to search groups') ->label('Login to search groups')
->url(route('admin.rbac.start', [ ->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(), 'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', $tenant), 'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
])); ]));
} }

View File

@ -3,9 +3,24 @@
namespace App\Filament\Resources\TenantResource\Pages; namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\TenantRole;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord class CreateTenant extends CreateRecord
{ {
protected static string $resource = TenantResource::class; protected static string $resource = TenantResource::class;
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => TenantRole::Owner->value],
]);
}
} }

View File

@ -9,6 +9,7 @@
use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
class ViewTenant extends ViewRecord class ViewTenant extends ViewRecord
@ -18,34 +19,63 @@ class ViewTenant extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\EditAction::make(), Actions\ActionGroup::make([
Actions\Action::make('admin_consent') Actions\EditAction::make(),
->label('Admin consent') Actions\Action::make('admin_consent')
->icon('heroicon-o-clipboard-document') ->label('Admin consent')
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) ->icon('heroicon-o-clipboard-document')
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
->openUrlInNewTab(), ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
Actions\Action::make('open_in_entra') ->openUrlInNewTab(),
->label('Open in Entra') Actions\Action::make('open_in_entra')
->icon('heroicon-o-arrow-top-right-on-square') ->label('Open in Entra')
->url(fn (Tenant $record) => TenantResource::entraUrl($record)) ->icon('heroicon-o-arrow-top-right-on-square')
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) ->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->openUrlInNewTab(), ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
Actions\Action::make('verify') ->openUrlInNewTab(),
->label('Verify configuration') Actions\Action::make('verify')
->icon('heroicon-o-check-badge') ->label('Verify configuration')
->color('primary') ->icon('heroicon-o-check-badge')
->requiresConfirmation() ->color('primary')
->action(function ( ->requiresConfirmation()
Tenant $record, ->action(function (
TenantConfigService $configService, Tenant $record,
TenantPermissionService $permissionService, TenantConfigService $configService,
RbacHealthService $rbacHealthService, TenantPermissionService $permissionService,
AuditLogger $auditLogger RbacHealthService $rbacHealthService,
) { AuditLogger $auditLogger
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); ) {
}), TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
TenantResource::rbacAction(), }),
TenantResource::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record) => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger) {
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]; ];
} }
} }

View File

@ -0,0 +1,100 @@
<?php
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Services\Intune\AuditLogger;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
class ApplyBackupScheduleRetentionJob implements ShouldQueue
{
use Queueable;
public function __construct(public int $backupScheduleId) {}
public function handle(AuditLogger $auditLogger): void
{
$schedule = BackupSchedule::query()
->with('tenant')
->find($this->backupScheduleId);
if (! $schedule || ! $schedule->tenant) {
return;
}
$keepLast = (int) ($schedule->retention_keep_last ?? 30);
if ($keepLast < 1) {
$keepLast = 1;
}
/** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->orderByDesc('scheduled_for')
->limit($keepLast)
->pluck('backup_set_id')
->filter()
->values();
/** @var Collection<int, int> $deleteBackupSetIds */
$deleteBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
->pluck('backup_set_id')
->filter()
->unique()
->values();
if ($deleteBackupSetIds->isEmpty()) {
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => 0,
],
],
);
return;
}
$deletedCount = 0;
BackupSet::query()
->where('tenant_id', $schedule->tenant_id)
->whereIn('id', $deleteBackupSetIds->all())
->whereNull('deleted_at')
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
foreach ($sets as $set) {
$set->delete();
$deletedCount++;
}
});
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => $deletedCount,
],
],
);
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkTenantSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
{
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
try {
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$itemCount = 0;
$supported = config('tenantpilot.supported_policy_types');
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $tenantId) {
$itemCount++;
try {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if (! $tenant) {
$service->recordFailure($run, (string) $tenantId, 'Tenant not found');
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $tenant->isActive()) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active');
continue;
}
if (! $run->user || ! $run->user->canSyncTenant($tenant)) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant');
continue;
}
$syncService->syncPolicies($tenant, $supported);
$service->recordSuccess($run);
} catch (Throwable $e) {
$service->recordFailure($run, (string) $tenantId, $e->getMessage());
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Synced {$run->succeeded} tenant(s)";
if ($run->skipped > 0) {
$message .= " ({$run->skipped} skipped)";
}
if ($run->failed > 0) {
$message .= " ({$run->failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Sync Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Sync Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use Carbon\CarbonImmutable;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
class RunBackupScheduleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public int $backupScheduleRunId,
public ?int $bulkRunId = null,
) {}
public function handle(
PolicySyncService $policySyncService,
BackupService $backupService,
PolicyTypeResolver $policyTypeResolver,
ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
BulkOperationService $bulkOperationService,
): void {
$run = BackupScheduleRun::query()
->with(['schedule', 'tenant', 'user'])
->find($this->backupScheduleRunId);
if (! $run) {
return;
}
$bulkRun = $this->bulkRunId
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
: null;
if (
$bulkRun
&& ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id)
) {
$bulkRun = null;
}
if ($bulkRun && $bulkRun->status === 'pending') {
$bulkOperationService->start($bulkRun);
}
$schedule = $run->schedule;
if (! $schedule instanceof BackupSchedule) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Schedule not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
return;
}
$tenant = $run->tenant;
if (! $tenant) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Tenant not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
return;
}
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
if (! $lock->get()) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'CONCURRENT_RUN',
errorMessage: 'Another run is already in progress for this schedule.',
summary: ['reason' => 'concurrent_run'],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
return;
}
try {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'started_at' => $run->started_at ?? $nowUtc,
'status' => BackupScheduleRun::STATUS_RUNNING,
])->save();
$this->notifyRunStarted($run, $schedule);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_started',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success'
);
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
$validTypes = $runtime['valid'];
$unknownTypes = $runtime['unknown'];
if (empty($validTypes)) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'UNKNOWN_POLICY_TYPE',
errorMessage: 'All configured policy types are unknown.',
summary: [
'unknown_policy_types' => $unknownTypes,
],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
return;
}
$supported = array_values(array_filter(
config('tenantpilot.supported_policy_types', []),
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
));
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
$policyIds = $syncReport['synced'] ?? [];
$syncFailures = $syncReport['failures'] ?? [];
$backupSet = $backupService->createBackupSet(
tenant: $tenant,
policyIds: $policyIds,
actorEmail: null,
actorName: null,
name: 'Scheduled backup: '.$schedule->name,
includeAssignments: false,
includeScopeTags: false,
includeFoundations: (bool) ($schedule->include_foundations ?? false),
);
$status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS,
};
$errorCode = null;
$errorMessage = null;
$summary = [
'policies_total' => count($policyIds),
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
'sync_failures' => $syncFailures,
];
if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.';
$summary['unknown_policy_types'] = $unknownTypes;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: $status,
errorCode: $errorCode,
errorMessage: $errorMessage,
summary: $summary,
scheduleTimeService: $scheduleTimeService,
backupSetId: (string) $backupSet->id,
bulkRunId: $this->bulkRunId,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_finished',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'status' => $status,
'error_code' => $errorCode,
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
);
} catch (\Throwable $throwable) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) {
$this->release($mapped['delay']);
return;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
errorCode: $mapped['error_code'],
errorMessage: $mapped['error_message'],
summary: [
'exception' => get_class($throwable),
'attempt' => $attempt,
],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_failed',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'error_code' => $mapped['error_code'],
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'failed'
);
} finally {
optional($lock)->release();
}
}
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$notification = Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info();
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$title = match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
$notification = Notification::make()
->title($title)
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
if (filled($run->error_message)) {
$notification->body($notification->getBody()."\n".$run->error_message);
}
match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
private function finishRun(
BackupScheduleRun $run,
BackupSchedule $schedule,
string $status,
?string $errorCode,
?string $errorMessage,
array $summary,
ScheduleTimeService $scheduleTimeService,
?string $backupSetId = null,
?int $bulkRunId = null,
): void {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'status' => $status,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'summary' => Arr::wrap($summary),
'finished_at' => $nowUtc,
'backup_set_id' => $backupSetId,
])->save();
$schedule->forceFill([
'last_run_at' => $nowUtc,
'last_run_status' => $status,
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
$this->notifyRunFinished($run, $schedule);
if ($bulkRunId) {
$bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId);
if (
$bulkRun
&& ($bulkRun->tenant_id === $run->tenant_id)
&& ($bulkRun->user_id === $run->user_id)
&& in_array($bulkRun->status, ['pending', 'running'], true)
) {
$service = app(BulkOperationService::class);
$itemId = (string) $run->backup_schedule_id;
match ($status) {
BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun),
BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason(
$bulkRun,
$itemId,
$errorMessage ?: 'Skipped',
),
BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure(
$bulkRun,
$itemId,
$errorMessage ?: 'Completed partially',
),
default => $service->recordFailure(
$bulkRun,
$itemId,
$errorMessage ?: ($errorCode ?: 'Failed'),
),
};
$bulkRun->refresh();
if (
in_array($bulkRun->status, ['pending', 'running'], true)
&& $bulkRun->processed_items >= $bulkRun->total_items
) {
$service->complete($bulkRun);
}
}
}
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
}
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace App\Livewire;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Intune\BackupService;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BackupSetPolicyPickerTable extends TableComponent
{
public int $backupSetId;
public bool $include_assignments = true;
public bool $include_scope_tags = true;
public bool $include_foundations = true;
public function mount(int $backupSetId): void
{
$this->backupSetId = $backupSetId;
}
public static function externalIdShort(?string $externalId): string
{
$value = (string) ($externalId ?? '');
$normalized = preg_replace('/[^A-Za-z0-9]/', '', $value) ?? '';
if ($normalized === '') {
return '—';
}
return substr($normalized, -8);
}
public function table(Table $table): Table
{
$backupSet = BackupSet::query()->find($this->backupSetId);
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$existingPolicyIds = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];
return $table
->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId))
->query(
Policy::query()
->where('tenant_id', $tenantId)
->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds))
)
->deferLoading(! app()->runningUnitTests())
->paginated([25, 50, 100])
->defaultPaginationPageOption(25)
->searchable()
->striped()
->columns([
TextColumn::make('display_name')
->label('Name')
->searchable()
->sortable()
->wrap(),
TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')),
TextColumn::make('platform')
->label('Platform')
->badge()
->default('—')
->sortable(),
TextColumn::make('external_id')
->label('External ID')
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
->tooltip(fn (?string $state): ?string => filled($state) ? $state : null)
->extraAttributes(['class' => 'font-mono text-xs'])
->toggleable(),
TextColumn::make('versions_count')
->label('Versions')
->state(fn (Policy $record): int => (int) ($record->versions_count ?? 0))
->badge()
->sortable(),
TextColumn::make('last_synced_at')
->label('Last synced')
->dateTime()
->since()
->sortable()
->toggleable(),
TextColumn::make('ignored_at')
->label('Ignored')
->badge()
->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray')
->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no')
->toggleable(isToggledHiddenByDefault: true),
])
->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions'))
->filters([
SelectFilter::make('policy_type')
->label('Policy type')
->options(static::policyTypeOptions()),
SelectFilter::make('platform')
->label('Platform')
->options(fn (): array => Policy::query()
->where('tenant_id', $tenantId)
->whereNotNull('platform')
->distinct()
->orderBy('platform')
->pluck('platform', 'platform')
->all()),
SelectFilter::make('synced_within')
->label('Last synced')
->options([
'7' => 'Within 7 days',
'30' => 'Within 30 days',
'90' => 'Within 90 days',
'any' => 'Any time',
])
->default('7')
->query(function (Builder $query, array $data): Builder {
$value = (string) ($data['value'] ?? '7');
if ($value === 'any') {
return $query;
}
$days = is_numeric($value) ? (int) $value : 7;
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
}),
TernaryFilter::make('ignored')
->label('Ignored')
->nullable()
->queries(
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
false: fn (Builder $query) => $query->whereNull('ignored_at'),
)
->default(false),
SelectFilter::make('has_versions')
->label('Has versions')
->options([
'1' => 'Has versions',
'0' => 'No versions',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if ($value === null || $value === '') {
return $query;
}
return match ((string) $value) {
'1' => $query->whereHas('versions'),
'0' => $query->whereDoesntHave('versions'),
default => $query,
};
}),
])
->bulkActions([
BulkAction::make('add_selected_to_backup_set')
->label('Add selected')
->icon('heroicon-m-plus')
->action(function (Collection $records, BackupService $service): void {
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
$tenant = $backupSet->tenant ?? Tenant::current();
$beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
$beforeFailureCount = count($beforeFailures);
$policyIds = $records->pluck('id')->all();
if ($policyIds === []) {
Notification::make()
->title('No policies selected')
->warning()
->send();
return;
}
$service->addPoliciesToSet(
tenant: $tenant,
backupSet: $backupSet,
policyIds: $policyIds,
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
includeAssignments: $this->include_assignments,
includeScopeTags: $this->include_scope_tags,
includeFoundations: $this->include_foundations,
);
$notificationTitle = $this->include_foundations
? 'Backup items added'
: 'Policies added to backup';
$backupSet->refresh();
$afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []);
$afterFailureCount = count($afterFailures);
if ($afterFailureCount > $beforeFailureCount) {
Notification::make()
->title($notificationTitle.' with failures')
->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.')
->warning()
->send();
} else {
Notification::make()
->title($notificationTitle)
->success()
->send();
}
$this->resetTable();
}),
]);
}
public function render(): View
{
return view('livewire.backup-set-policy-picker-table');
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
return collect($types)
->firstWhere('type', $type) ?? [];
}
/**
* @return array<string, string>
*/
private static function policyTypeOptions(): array
{
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
return collect($types)
->mapWithKeys(function (array $meta): array {
$type = (string) ($meta['type'] ?? '');
if ($type === '') {
return [];
}
$label = (string) ($meta['label'] ?? $type);
return [$type => $label];
})
->all();
}
}

View File

@ -2,8 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun; use App\Models\BulkOperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Support\Arr;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
@ -13,9 +15,12 @@ class BulkOperationProgress extends Component
public int $pollSeconds = 3; public int $pollSeconds = 3;
public int $recentFinishedSeconds = 12;
public function mount() public function mount()
{ {
$this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
$this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12)));
$this->loadRuns(); $this->loadRuns();
} }
@ -35,12 +40,102 @@ public function loadRuns()
return; return;
} }
$recentThreshold = now()->subSeconds($this->recentFinishedSeconds);
$this->runs = BulkOperationRun::query() $this->runs = BulkOperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('user_id', auth()->id()) ->where('user_id', auth()->id())
->whereIn('status', ['pending', 'running']) ->where(function ($query) use ($recentThreshold): void {
$query->whereIn('status', ['pending', 'running'])
->orWhere(function ($query) use ($recentThreshold): void {
$query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted'])
->where('updated_at', '>=', $recentThreshold);
});
})
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(); ->get();
$this->reconcileBackupScheduleRuns($tenant->id);
}
private function reconcileBackupScheduleRuns(int $tenantId): void
{
$userId = auth()->id();
if (! $userId) {
return;
}
$staleThreshold = now()->subSeconds(60);
foreach ($this->runs as $bulkRun) {
if ($bulkRun->resource !== 'backup_schedule') {
continue;
}
if (! in_array($bulkRun->status, ['pending', 'running'], true)) {
continue;
}
if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) {
continue;
}
$scheduleId = (int) Arr::first($bulkRun->item_ids ?? []);
if ($scheduleId <= 0) {
continue;
}
$scheduleRun = BackupScheduleRun::query()
->where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('backup_schedule_id', $scheduleId)
->where('created_at', '>=', $bulkRun->created_at)
->orderByDesc('id')
->first();
if (! $scheduleRun) {
continue;
}
if ($scheduleRun->finished_at) {
$processed = 1;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$status = 'completed';
switch ($scheduleRun->status) {
case BackupScheduleRun::STATUS_SUCCESS:
$succeeded = 1;
break;
case BackupScheduleRun::STATUS_SKIPPED:
$skipped = 1;
break;
default:
$failed = 1;
$status = 'completed_with_errors';
break;
}
$bulkRun->forceFill([
'status' => $status,
'processed_items' => $processed,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
])->save();
continue;
}
if ($scheduleRun->started_at && $bulkRun->status === 'pending') {
$bulkRun->forceFill(['status' => 'running'])->save();
}
}
} }
public function render(): \Illuminate\Contracts\View\View public function render(): \Illuminate\Contracts\View\View

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupSchedule extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'is_enabled' => 'boolean',
'include_foundations' => 'boolean',
'days_of_week' => 'array',
'policy_types' => 'array',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function runs(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BackupScheduleRun extends Model
{
use HasFactory;
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
public const STATUS_SKIPPED = 'skipped';
protected $guarded = [];
protected $casts = [
'scheduled_for' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'summary' => 'array',
];
public function schedule(): BelongsTo
{
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function backupSet(): BelongsTo
{
return $this->belongsTo(BackupSet::class);
}
}

View File

@ -2,16 +2,19 @@
namespace App\Models; namespace App\Models;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
class Tenant extends Model class Tenant extends Model implements HasName
{ {
use HasFactory; use HasFactory;
use SoftDeletes; use SoftDeletes;
@ -104,13 +107,23 @@ public function makeCurrent(): void
DB::transaction(function () { DB::transaction(function () {
static::activeQuery()->update(['is_current' => false]); static::activeQuery()->update(['is_current' => false]);
$this->forceFill(['is_current' => true])->save(); static::query()
->whereKey($this->getKey())
->update(['is_current' => true]);
}); });
$this->forceFill(['is_current' => true]);
} }
public static function current(): self public static function current(): self
{ {
$envTenantId = env('INTUNE_TENANT_ID') ?: null; $filamentTenant = Filament::getTenant();
if ($filamentTenant instanceof self) {
return $filamentTenant;
}
$envTenantId = getenv('INTUNE_TENANT_ID') ?: null;
if ($envTenantId) { if ($envTenantId) {
$tenant = static::activeQuery() $tenant = static::activeQuery()
@ -138,6 +151,20 @@ public static function current(): self
return $tenant; return $tenant;
} }
public function getFilamentName(): string
{
$environment = strtoupper((string) ($this->environment ?? 'other'));
return "{$this->name} ({$environment})";
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps();
}
public function policies(): HasMany public function policies(): HasMany
{ {
return $this->hasMany(Policy::class); return $this->hasMany(Policy::class);
@ -148,6 +175,16 @@ public function backupSets(): HasMany
return $this->hasMany(BackupSet::class); return $this->hasMany(BackupSet::class);
} }
public function backupSchedules(): HasMany
{
return $this->hasMany(BackupSchedule::class);
}
public function backupScheduleRuns(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function policyVersions(): HasMany public function policyVersions(): HasMany
{ {
return $this->hasMany(PolicyVersion::class); return $this->hasMany(PolicyVersion::class);

View File

@ -2,13 +2,21 @@
namespace App\Models; namespace App\Models;
use App\Support\TenantRole;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
class User extends Authenticatable implements FilamentUser class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;
} }
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class)
->withPivot('role')
->withTimestamps();
}
public function tenantPreferences(): HasMany
{
return $this->hasMany(UserTenantPreference::class);
}
private function tenantPivotTableExists(): bool
{
static $exists;
return $exists ??= Schema::hasTable('tenant_user');
}
private function tenantPreferencesTableExists(): bool
{
static $exists;
return $exists ??= Schema::hasTable('user_tenant_preferences');
}
public function tenantRole(Tenant $tenant): ?TenantRole
{
if (! $this->tenantPivotTableExists()) {
return null;
}
$role = $this->tenants()
->whereKey($tenant->getKey())
->value('role');
if (! is_string($role)) {
return null;
}
return TenantRole::tryFrom($role);
}
public function canSyncTenant(Tenant $tenant): bool
{
$role = $this->tenantRole($tenant);
return $role?->canSync() ?? false;
}
public function canAccessTenant(Model $tenant): bool
{
if (! $tenant instanceof Tenant) {
return false;
}
if (! $this->tenantPivotTableExists()) {
return false;
}
return $this->tenants()
->whereKey($tenant->getKey())
->exists();
}
public function getTenants(Panel $panel): array|Collection
{
if (! $this->tenantPivotTableExists()) {
return collect();
}
return $this->tenants()
->where('status', 'active')
->orderBy('name')
->get();
}
public function getDefaultTenant(Panel $panel): ?Model
{
if (! $this->tenantPivotTableExists()) {
return null;
}
$tenantId = null;
if ($this->tenantPreferencesTableExists()) {
$tenantId = $this->tenantPreferences()
->whereNotNull('last_used_at')
->orderByDesc('last_used_at')
->value('tenant_id');
}
if ($tenantId !== null) {
$tenant = $this->tenants()
->where('status', 'active')
->whereKey($tenantId)
->first();
if ($tenant !== null) {
return $tenant;
}
}
return $this->tenants()
->where('status', 'active')
->orderBy('name')
->first();
}
} }

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserTenantPreference extends Model
{
protected $guarded = [];
protected $casts = [
'is_favorite' => 'boolean',
'last_used_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class BackupScheduleRunDispatchedNotification extends Notification
{
/**
* @param array{
* tenant_id:int,
* trigger:string,
* scheduled_for:string,
* backup_schedule_id?:int,
* backup_schedule_run_id?:int,
* schedule_ids?:array<int, int>,
* backup_schedule_run_ids?:array<int, int>
* } $metadata
*/
public function __construct(public array $metadata) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$trigger = (string) ($this->metadata['trigger'] ?? 'run_now');
$title = match ($trigger) {
'retry' => 'Retry dispatched',
'bulk_retry' => 'Retries dispatched',
'bulk_run_now' => 'Runs dispatched',
default => 'Run dispatched',
};
$body = match ($trigger) {
'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.',
default => 'A backup run has been queued.',
};
return [
'title' => $title,
'body' => $body,
'metadata' => $this->metadata,
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Policies;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use Illuminate\Auth\Access\HandlesAuthorization;
class BackupSchedulePolicy
{
use HandlesAuthorization;
protected function resolveRole(User $user): ?TenantRole
{
$tenant = Tenant::current();
return $user->tenantRole($tenant);
}
public function viewAny(User $user): bool
{
return $this->resolveRole($user) !== null;
}
public function view(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user) !== null;
}
public function create(User $user): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
public function update(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
public function delete(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
}
}

View File

@ -2,14 +2,31 @@
namespace App\Providers; namespace App\Providers;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Policies\BackupSchedulePolicy;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient; use App\Services\Graph\NullGraphClient;
use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\AppProtectionPolicyNormalizer;
use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
use App\Services\Intune\ScriptsPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\TermsAndConditionsNormalizer;
use App\Services\Intune\WindowsDriverUpdateProfileNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use Filament\Events\TenantSet;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -38,8 +55,16 @@ public function register(): void
AppProtectionPolicyNormalizer::class, AppProtectionPolicyNormalizer::class,
CompliancePolicyNormalizer::class, CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class,
EnrollmentAutopilotPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class, GroupPolicyConfigurationNormalizer::class,
ManagedDeviceAppConfigurationNormalizer::class,
ScriptsPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,
TermsAndConditionsNormalizer::class,
WindowsDriverUpdateProfileNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class,
], ],
'policy-type-normalizers' 'policy-type-normalizers'
); );
@ -50,6 +75,37 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
// Event::listen(TenantSet::class, function (TenantSet $event): void {
static $hasPreferencesTable;
$hasPreferencesTable ??= Schema::hasTable('user_tenant_preferences');
if (! $hasPreferencesTable) {
return;
}
$tenant = $event->getTenant();
$user = $event->getUser();
if (! $tenant instanceof Tenant) {
return;
}
if (! $user instanceof User) {
return;
}
UserTenantPreference::query()->updateOrCreate(
[
'user_id' => $user->getKey(),
'tenant_id' => $tenant->getKey(),
],
[
'last_used_at' => now(),
],
);
});
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
} }
} }

View File

@ -2,6 +2,8 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Models\Tenant;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login()
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])

View File

@ -0,0 +1,22 @@
<?php
namespace App\Rules;
use App\Exceptions\InvalidPolicyTypeException;
use App\Services\BackupScheduling\PolicyTypeResolver;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SupportedPolicyTypesRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$types = array_values((array) $value);
try {
app(PolicyTypeResolver::class)->ensureSupported($types);
} catch (InvalidPolicyTypeException $exception) {
$fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes)));
}
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Services\BackupScheduling;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use Carbon\CarbonImmutable;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
class BackupScheduleDispatcher
{
public function __construct(
private readonly ScheduleTimeService $scheduleTimeService,
private readonly AuditLogger $auditLogger,
) {}
/**
* Dispatch due schedules.
*
* No catch-up policy: we only dispatch if the current minute-slot is due.
*
* @return array{created_runs:int, skipped_runs:int, scanned_schedules:int}
*/
public function dispatchDue(?array $tenantIdentifiers = null): array
{
$nowUtc = CarbonImmutable::now('UTC');
$schedulesQuery = BackupSchedule::query()
->where('is_enabled', true)
->whereHas('tenant', fn ($query) => $query->where('status', 'active'))
->with('tenant');
if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) {
$schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers));
}
$createdRuns = 0;
$skippedRuns = 0;
$scannedSchedules = 0;
foreach ($schedulesQuery->cursor() as $schedule) {
$scannedSchedules++;
$slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute());
if ($slot === null) {
$schedule->forceFill(['next_run_at' => null])->saveQuietly();
continue;
}
if ($slot->greaterThan($nowUtc)) {
if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) {
$schedule->forceFill(['next_run_at' => $slot])->saveQuietly();
}
continue;
}
$run = null;
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $schedule->tenant_id,
'scheduled_for' => $slot->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
} catch (UniqueConstraintViolationException) {
// Idempotency: unique (backup_schedule_id, scheduled_for)
$skippedRuns++;
Log::debug('Backup schedule run already dispatched for slot.', [
'schedule_id' => $schedule->id,
'slot' => $slot->toDateTimeString(),
]);
$schedule->forceFill([
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
continue;
}
$createdRuns++;
$this->auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.run_dispatched',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $slot->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success'
);
$schedule->forceFill([
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
Bus::dispatch(new RunBackupScheduleJob($run->id));
}
return [
'created_runs' => $createdRuns,
'skipped_runs' => $skippedRuns,
'scanned_schedules' => $scannedSchedules,
];
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->where('status', 'active')
->forTenant($identifier)
->first();
if ($tenant) {
$tenantIds[] = $tenant->id;
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\BackupScheduling;
use App\Exceptions\InvalidPolicyTypeException;
use Illuminate\Support\Arr;
class PolicyTypeResolver
{
public function supportedPolicyTypes(): array
{
return Arr::pluck(config('tenantpilot.supported_policy_types', []), 'type');
}
public function ensureSupported(array $types): void
{
$unknown = $this->findUnknown($types);
if (! empty($unknown)) {
throw new InvalidPolicyTypeException($unknown);
}
}
public function filterRuntime(array $types): array
{
$valid = $this->filter($types);
return array_values($valid);
}
public function resolveRuntime(array $types): array
{
$valid = $this->filter($types);
$unknown = $this->findUnknown($types);
return [
'valid' => array_values($valid),
'unknown' => array_values($unknown),
];
}
protected function filter(array $types): array
{
$supported = $this->supportedPolicyTypes();
return array_values(array_intersect($types, $supported));
}
protected function findUnknown(array $types): array
{
$supported = $this->supportedPolicyTypes();
return array_values(array_diff($types, $supported));
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\BackupScheduling;
use App\Services\Graph\GraphException;
use Throwable;
class RunErrorMapper
{
public const ERROR_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
public const ERROR_PERMISSION_MISSING = 'PERMISSION_MISSING';
public const ERROR_GRAPH_THROTTLE = 'GRAPH_THROTTLE';
public const ERROR_GRAPH_UNAVAILABLE = 'GRAPH_UNAVAILABLE';
public const ERROR_UNKNOWN = 'UNKNOWN';
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
public function map(Throwable $throwable, int $attempt, int $maxAttempts = 3): array
{
$attempt = max(1, $attempt);
if ($throwable instanceof GraphException) {
$status = $throwable->status;
if ($status === 401) {
return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage());
}
if ($status === 403) {
return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage());
}
if ($status === 429) {
return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts);
}
if ($status === 503) {
return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts);
}
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
}
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
}
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
private function retry(string $code, string $message, int $attempt, int $maxAttempts): array
{
if ($attempt >= $maxAttempts) {
return $this->final($code, $message);
}
$delays = [60, 300, 900];
$delay = $delays[min($attempt - 1, count($delays) - 1)];
return [
'shouldRetry' => true,
'delay' => $delay,
'error_code' => $code,
'error_message' => $message,
'final_status' => 'failed',
];
}
/**
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
*/
private function final(string $code, string $message): array
{
return [
'shouldRetry' => false,
'delay' => 0,
'error_code' => $code,
'error_message' => $message,
'final_status' => 'failed',
];
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Services\BackupScheduling;
use App\Models\BackupSchedule;
use Carbon\CarbonImmutable;
class ScheduleTimeService
{
public function nextRunFor(BackupSchedule $schedule, ?CarbonImmutable $after = null): ?CarbonImmutable
{
$timezone = $schedule->timezone;
$cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone);
if ($schedule->frequency === 'weekly') {
return $this->nextWeeklyRun($schedule, $cursor);
}
return $this->nextDailyRun($schedule, $cursor);
}
protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
{
$time = $schedule->time_of_day;
$attempts = 0;
if ($cursor->format('H:i:s') >= $time) {
$cursor = $cursor->addDay();
}
while ($attempts++ < 14) {
$candidate = $this->buildLocalSlot($schedule, $cursor);
if ($candidate) {
return $candidate;
}
$cursor = $cursor->addDay();
}
return null;
}
protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
{
$allowed = $schedule->days_of_week ?? [];
$allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7);
$allowed = array_values($allowed);
if (empty($allowed)) {
return null;
}
$attempts = 0;
while ($attempts++ < 21) {
$dayOfWeek = $cursor->dayOfWeekIso;
if (in_array($dayOfWeek, $allowed, true)) {
$candidate = $this->buildLocalSlot($schedule, $cursor);
$cursorUtc = $cursor->copy()->timezone('UTC');
if ($candidate && $candidate->greaterThan($cursorUtc)) {
return $candidate;
}
}
$cursor = $cursor->addDay()->startOfDay();
}
return null;
}
protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable
{
$timezone = $schedule->timezone;
$time = $schedule->time_of_day;
$datePart = $date->format('Y-m-d');
$candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone);
if (! $candidate || $candidate->format('H:i:s') !== $time) {
return null;
}
return $candidate->startOfMinute()->timezone('UTC');
}
}

View File

@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
public function complete(BulkOperationRun $run): void public function complete(BulkOperationRun $run): void
{ {
$run->refresh();
if (! in_array($run->status, ['pending', 'running'], true)) {
return;
}
$status = $run->failed > 0 ? 'completed_with_errors' : 'completed'; $status = $run->failed > 0 ? 'completed_with_errors' : 'completed';
$run->update(['status' => $status]);
$updated = BulkOperationRun::query()
->whereKey($run->id)
->whereIn('status', ['pending', 'running'])
->update(['status' => $status]);
if ($updated === 0) {
return;
}
$run->refresh();
$failureEntries = collect($run->failures ?? []); $failureEntries = collect($run->failures ?? []);
$failedReasons = $failureEntries $failedReasons = $failureEntries

View File

@ -39,7 +39,7 @@ public function fetch(
$primaryException = null; $primaryException = null;
$assignments = []; $assignments = [];
$primarySucceeded = false; $lastSuccessfulAssignments = null;
// Try primary endpoint(s) // Try primary endpoint(s)
$listPathTemplates = []; $listPathTemplates = [];
@ -65,7 +65,12 @@ public function fetch(
$context, $context,
$throwOnFailure $throwOnFailure
); );
$primarySucceeded = true;
if ($assignments === null) {
continue;
}
$lastSuccessfulAssignments = $assignments;
if (! empty($assignments)) { if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [ Log::debug('Fetched assignments via primary endpoint', [
@ -77,20 +82,25 @@ public function fetch(
return $assignments; return $assignments;
} }
if ($policyType !== 'appProtectionPolicy') {
// Empty is a valid outcome (policy not assigned). Do not attempt fallback.
return [];
}
} catch (GraphException $e) { } catch (GraphException $e) {
$primaryException = $primaryException ?? $e; $primaryException = $primaryException ?? $e;
} }
} }
if ($primarySucceeded && $policyType === 'appProtectionPolicy') { if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') {
Log::debug('Assignments fetched via primary endpoint(s)', [ Log::debug('Assignments fetched via primary endpoint(s)', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType, 'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
'count' => count($assignments), 'count' => count($lastSuccessfulAssignments),
]); ]);
return $assignments; return $lastSuccessfulAssignments;
} }
// Try fallback with $expand // Try fallback with $expand
@ -215,15 +225,15 @@ private function fetchPrimary(
array $options, array $options,
array $context, array $context,
bool $throwOnFailure bool $throwOnFailure
): array { ): ?array {
if (! is_string($listPathTemplate) || $listPathTemplate === '') { if (! is_string($listPathTemplate) || $listPathTemplate === '') {
return []; return null;
} }
$path = $this->resolvePath($listPathTemplate, $policyId); $path = $this->resolvePath($listPathTemplate, $policyId);
if ($path === null) { if ($path === null) {
return []; return null;
} }
$response = $this->graphClient->request('GET', $path, $options); $response = $this->graphClient->request('GET', $path, $options);
@ -239,7 +249,7 @@ private function fetchPrimary(
); );
} }
return []; return null;
} }
return $response->data['value'] ?? []; return $response->data['value'] ?? [];

View File

@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array
: array_map('trim', explode(',', (string) $original)); : array_map('trim', explode(',', (string) $original));
$filtered = array_values(array_intersect($select, $allowedSelect)); $filtered = array_values(array_intersect($select, $allowedSelect));
$withoutAnnotations = array_values(array_filter(
$filtered,
static fn ($field) => is_string($field) && ! str_contains($field, '@')
));
if (count($withoutAnnotations) !== count($filtered)) {
$warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).';
$filtered = $withoutAnnotations;
}
if (count($filtered) !== count($select)) { if (count($filtered) !== count($select)) {
$warnings[] = 'Trimmed unsupported $select fields for capability safety.'; $warnings[] = 'Trimmed unsupported $select fields for capability safety.';
} }

View File

@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface
{ {
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
private const MAX_LIST_PAGES = 50;
private string $baseUrl; private string $baseUrl;
private string $tokenUrlTemplate; private string $tokenUrlTemplate;
@ -51,12 +53,21 @@ public function __construct(
public function listPolicies(string $policyType, array $options = []): GraphResponse public function listPolicies(string $policyType, array $options = []): GraphResponse
{ {
$endpoint = $this->endpointFor($policyType); $endpoint = $this->endpointFor($policyType);
$query = array_filter([ $contract = $this->contracts->get($policyType);
$allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
$defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);
$queryInput = array_filter([
'$top' => $options['top'] ?? null, '$top' => $options['top'] ?? null,
'$filter' => $options['filter'] ?? null, '$filter' => $options['filter'] ?? null,
'$select' => $defaultSelect,
'platform' => $options['platform'] ?? null, 'platform' => $options['platform'] ?? null,
], fn ($value) => $value !== null && $value !== ''); ], fn ($value) => $value !== null && $value !== '');
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
$query = $sanitized['query'];
$warnings = $sanitized['warnings'];
$context = $this->resolveContext($options); $context = $this->resolveContext($options);
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint, $query); $fullPath = $this->buildFullPath($endpoint, $query);
@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
$response = $this->send('GET', $endpoint, $sendOptions, $context); $response = $this->send('GET', $endpoint, $sendOptions, $context);
return $this->toGraphResponse( if ($response->failed()) {
action: 'list_policies', $graphResponse = $this->toGraphResponse(
response: $response, action: 'list_policies',
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), response: $response,
meta: [ transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
'tenant' => $context['tenant'] ?? null, meta: [
'path' => $endpoint, 'tenant' => $context['tenant'] ?? null,
'full_path' => $fullPath, 'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
],
warnings: $warnings,
);
if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
return $graphResponse;
}
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$fallbackSendOptions['access_token'] = $options['access_token'];
}
$this->logger->logRequest('list_policies_fallback', [
'endpoint' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET', 'method' => 'GET',
'query' => $query ?: null, 'policy_type' => $policyType,
'tenant' => $context['tenant'],
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId, 'client_request_id' => $clientRequestId,
] ]);
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
if ($fallbackResponse->failed()) {
return $this->toGraphResponse(
action: 'list_policies',
response: $fallbackResponse,
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fallbackPath,
'method' => 'GET',
'query' => $fallbackQuery ?: null,
'client_request_id' => $clientRequestId,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
))),
);
}
$response = $fallbackResponse;
$query = $fallbackQuery;
$fullPath = $fallbackPath;
$warnings = array_values(array_unique(array_merge(
$warnings,
['Capability fallback applied: removed $select for compatibility.']
)));
}
$json = $response->json() ?? [];
$policies = $json['value'] ?? (is_array($json) ? $json : []);
$nextLink = $json['@odata.nextLink'] ?? null;
$pages = 1;
while (is_string($nextLink) && $nextLink !== '') {
if ($pages >= self::MAX_LIST_PAGES) {
$graphResponse = new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph pagination exceeded maximum page limit.',
'max_pages' => self::MAX_LIST_PAGES,
]],
warnings: $warnings,
meta: [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
'pages_fetched' => $pages,
],
);
$this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);
return $graphResponse;
}
$pageOptions = ['client_request_id' => $clientRequestId];
if (isset($options['access_token'])) {
$pageOptions['access_token'] = $options['access_token'];
}
$pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);
if ($pageResponse->failed()) {
$graphResponse = $this->toGraphResponse(
action: 'list_policies',
response: $pageResponse,
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,
'pages_fetched' => $pages,
],
warnings: array_values(array_unique(array_merge(
$warnings,
['Pagination failed while listing policies.']
))),
);
return $graphResponse;
}
$pageJson = $pageResponse->json() ?? [];
$pageValue = $pageJson['value'] ?? [];
if (is_array($pageValue) && $pageValue !== []) {
$policies = array_merge($policies, $pageValue);
}
$nextLink = $pageJson['@odata.nextLink'] ?? null;
$pages++;
}
$meta = $this->responseMeta($response, [
'tenant' => $context['tenant'] ?? null,
'path' => $endpoint,
'full_path' => $fullPath,
'method' => 'GET',
'query' => $query ?: null,
'client_request_id' => $clientRequestId,
]);
$meta['pages_fetched'] = $pages;
$meta['item_count'] = count($policies);
if ($pages > 1) {
$warnings = array_values(array_unique(array_merge($warnings, [
sprintf('Pagination applied: fetched %d pages.', $pages),
])));
}
$graphResponse = new GraphResponse(
success: true,
data: $policies,
status: $response->status(),
warnings: $warnings,
meta: $meta,
); );
$this->logger->logResponse('list_policies', $graphResponse, $meta);
return $graphResponse;
} }
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options =
return $graphResponse; return $graphResponse;
} }
private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
{
if (! $graphResponse->failed()) {
return false;
}
if (($graphResponse->status ?? null) !== 400) {
return false;
}
if (! array_key_exists('$select', $query)) {
return false;
}
$errorMessage = $graphResponse->meta['error_message'] ?? null;
if (! is_string($errorMessage) || $errorMessage === '') {
return false;
}
if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
return true;
}
if (stripos($errorMessage, 'Could not find a property named') !== false) {
return true;
}
return false;
}
public function getOrganization(array $options = []): GraphResponse public function getOrganization(array $options = []): GraphResponse
{ {
$context = $this->resolveContext($options); $context = $this->resolveContext($options);
@ -575,8 +776,22 @@ private function normalizeScopes(array|string|null $scope): array
private function endpointFor(string $policyType): string private function endpointFor(string $policyType): string
{ {
$supported = config('tenantpilot.supported_policy_types', []); $contractResource = $this->contracts->resourcePath($policyType);
foreach ($supported as $type) { if (is_string($contractResource) && $contractResource !== '') {
return $contractResource;
}
$builtinEndpoint = $this->builtinEndpointFor($policyType);
if ($builtinEndpoint !== null) {
return $builtinEndpoint;
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', []),
);
foreach ($types as $type) {
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
return $type['endpoint']; return $type['endpoint'];
} }
@ -585,6 +800,16 @@ private function endpointFor(string $policyType): string
return 'deviceManagement/'.$policyType; return 'deviceManagement/'.$policyType;
} }
private function builtinEndpointFor(string $policyType): ?string
{
return match ($policyType) {
'settingsCatalogPolicy',
'endpointSecurityPolicy',
'securityBaselinePolicy' => 'deviceManagement/configurationPolicies',
default => null,
};
}
private function getAccessToken(array $context): string private function getAccessToken(array $context): string
{ {
$tenant = $context['tenant'] ?? $this->tenantId; $tenant = $context['tenant'] ?? $this->tenantId;

View File

@ -5,6 +5,7 @@
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\AssignmentBackupService; use App\Services\AssignmentBackupService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -289,13 +290,46 @@ private function snapshotPolicy(
$captured = $captureResult['captured']; $captured = $captureResult['captured'];
$payload = $captured['payload']; $payload = $captured['payload'];
$metadata = $captured['metadata'] ?? []; $metadata = $captured['metadata'] ?? [];
$metadataWarnings = $captured['warnings'] ?? [];
// Validate snapshot return [
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $this->createBackupItemFromVersion(
tenant: $tenant,
backupSet: $backupSet,
policy: $policy,
version: $version,
payload: is_array($payload) ? $payload : [],
assignments: $captured['assignments'] ?? null,
scopeTags: $captured['scope_tags'] ?? null,
metadata: is_array($metadata) ? $metadata : [],
warnings: $captured['warnings'] ?? [],
),
null,
];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $metadata
* @param array<int, string> $warnings
* @param array{ids:array<int, string>,names:array<int, string>}|null $scopeTags
*/
private function createBackupItemFromVersion(
Tenant $tenant,
BackupSet $backupSet,
Policy $policy,
PolicyVersion $version,
array $payload,
?array $assignments,
?array $scopeTags,
array $metadata,
array $warnings = [],
): BackupItem {
$metadataWarnings = $warnings;
$validation = $this->snapshotValidator->validate($payload);
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
$odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform); $odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform);
if ($odataWarning) { if ($odataWarning) {
$metadataWarnings[] = $odataWarning; $metadataWarnings[] = $odataWarning;
@ -305,29 +339,23 @@ private function snapshotPolicy(
$metadata['warnings'] = array_values(array_unique($metadataWarnings)); $metadata['warnings'] = array_values(array_unique($metadataWarnings));
} }
$capturedScopeTags = $captured['scope_tags'] ?? null; if (is_array($scopeTags)) {
if (is_array($capturedScopeTags)) { $metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null;
$metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; $metadata['scope_tag_names'] = $scopeTags['names'] ?? null;
$metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null;
} }
// Create BackupItem as a copy/reference of the PolicyVersion return BackupItem::create([
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'policy_version_id' => $version->id, // Link to version 'policy_version_id' => $version->id,
'policy_identifier' => $policy->external_id, 'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
'payload' => $payload, 'payload' => $payload,
'metadata' => $metadata, 'metadata' => $metadata,
// Copy assignments from version (already captured) 'assignments' => $assignments,
// Note: scope_tags are only stored in PolicyVersion
'assignments' => $captured['assignments'] ?? null,
]); ]);
return [$backupItem, null];
} }
/** /**

View File

@ -0,0 +1,388 @@
<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Support\Arr;
class ConfigurationPolicyTemplateResolver
{
/**
* @var array<string, array<string, array{success:bool,template:?array,reason:?string}>>
*/
private array $templateCache = [];
/**
* @var array<string, array<string, array{success:bool,templates:array<int,array>,reason:?string}>>
*/
private array $familyCache = [];
/**
* @var array<string, array<string, array{success:bool,definition_ids:array<int,string>,reason:?string}>>
*/
private array $templateDefinitionCache = [];
public function __construct(
private readonly GraphClientInterface $graphClient,
) {}
/**
* @param array<string, mixed> $templateReference
* @param array<string, mixed> $graphOptions
* @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array<int,string>}
*/
public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array
{
$warnings = [];
$templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']);
$templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']);
$templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']);
$templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']);
if ($templateId !== null) {
$templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions);
if ($templateOutcome['success']) {
return [
'success' => true,
'template_id' => $templateId,
'template_reference' => $templateReference,
'reason' => null,
'warnings' => $warnings,
];
}
if ($templateFamily === null) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.",
'warnings' => $warnings,
];
}
}
if ($templateFamily === null) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => 'Template reference is missing templateFamily and cannot be resolved.',
'warnings' => $warnings,
];
}
$listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions);
if (! $listOutcome['success']) {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.",
'warnings' => $warnings,
];
}
$candidates = $this->chooseTemplateCandidate(
templates: $listOutcome['templates'],
templateDisplayName: $templateDisplayName,
templateDisplayVersion: $templateDisplayVersion,
);
if (count($candidates) !== 1) {
$reason = count($candidates) === 0
? "No templates found for family '{$templateFamily}'."
: "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically).";
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => $reason,
'warnings' => $warnings,
];
}
$candidate = $candidates[0];
$resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null;
if (! is_string($resolvedId) || $resolvedId === '') {
return [
'success' => false,
'template_id' => null,
'template_reference' => null,
'reason' => "Template candidate for family '{$templateFamily}' is missing an id.",
'warnings' => $warnings,
];
}
if ($templateId !== null && $templateId !== $resolvedId) {
$warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId);
}
$templateReference['templateId'] = $resolvedId;
if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) {
$templateReference['templateDisplayName'] = $candidate['displayName'];
}
if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) {
$templateReference['templateDisplayVersion'] = $candidate['displayVersion'];
}
return [
'success' => true,
'template_id' => $resolvedId,
'template_reference' => $templateReference,
'reason' => null,
'warnings' => $warnings,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,template:?array,reason:?string}
*/
public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
if (isset($this->templateCache[$tenantKey][$templateId])) {
return $this->templateCache[$tenantKey][$templateId];
}
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']));
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId));
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {
return $this->templateCache[$tenantKey][$templateId] = [
'success' => false,
'template' => null,
'reason' => $response->meta['error_message'] ?? 'Template lookup failed.',
];
}
return $this->templateCache[$tenantKey][$templateId] = [
'success' => true,
'template' => $response->data,
'reason' => null,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,templates:array<int,array>,reason:?string}
*/
public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
$cacheKey = strtolower($templateFamily);
if (isset($this->familyCache[$tenantKey][$cacheKey])) {
return $this->familyCache[$tenantKey][$cacheKey];
}
$escapedFamily = str_replace("'", "''", $templateFamily);
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [
'query' => [
'$filter' => "templateFamily eq '{$escapedFamily}'",
'$top' => 999,
],
]);
$response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context);
if ($response->failed()) {
return $this->familyCache[$tenantKey][$cacheKey] = [
'success' => false,
'templates' => [],
'reason' => $response->meta['error_message'] ?? 'Template list failed.',
];
}
$value = $response->data['value'] ?? [];
$templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : [];
return $this->familyCache[$tenantKey][$cacheKey] = [
'success' => true,
'templates' => $templates,
'reason' => null,
];
}
/**
* @param array<string, mixed> $graphOptions
* @return array{success:bool,definition_ids:array<int,string>,reason:?string}
*/
public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array
{
$tenantKey = $this->tenantKey($tenant, $graphOptions);
if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) {
return $this->templateDefinitionCache[$tenantKey][$templateId];
}
$context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [
'query' => [
'$expand' => 'settingDefinitions',
'$top' => 999,
],
]);
$path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId));
$response = $this->graphClient->request('GET', $path, $context);
if ($response->failed()) {
return $this->templateDefinitionCache[$tenantKey][$templateId] = [
'success' => false,
'definition_ids' => [],
'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.',
];
}
$value = $response->data['value'] ?? [];
$templates = is_array($value) ? $value : [];
$definitionIds = [];
foreach ($templates as $settingTemplate) {
if (! is_array($settingTemplate)) {
continue;
}
$definitions = $settingTemplate['settingDefinitions'] ?? null;
if (! is_array($definitions)) {
continue;
}
foreach ($definitions as $definition) {
if (! is_array($definition)) {
continue;
}
$id = $definition['id'] ?? null;
if (is_string($id) && $id !== '') {
$definitionIds[] = $id;
}
}
}
$definitionIds = array_values(array_unique($definitionIds));
return $this->templateDefinitionCache[$tenantKey][$templateId] = [
'success' => true,
'definition_ids' => $definitionIds,
'reason' => null,
];
}
/**
* @param array<int, mixed> $settings
* @return array<int, string>
*/
public function extractSettingDefinitionIds(array $settings): array
{
$ids = [];
$walk = function (mixed $node) use (&$walk, &$ids): void {
if (! is_array($node)) {
return;
}
foreach ($node as $key => $value) {
if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') {
$ids[] = $value;
}
$walk($value);
}
};
$walk($settings);
return array_values(array_unique($ids));
}
/**
* @param array<int, array> $templates
* @return array<int, array>
*/
private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array
{
$candidates = $templates;
$active = array_values(array_filter($candidates, static function (array $template): bool {
$state = $template['lifecycleState'] ?? null;
return is_string($state) && strtolower($state) === 'active';
}));
if ($active !== []) {
$candidates = $active;
}
if ($templateDisplayVersion !== null) {
$byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool {
$version = $template['displayVersion'] ?? null;
return is_string($version) && $version === $templateDisplayVersion;
}));
if ($byVersion !== []) {
$candidates = $byVersion;
}
}
if ($templateDisplayName !== null) {
$byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool {
$name = $template['displayName'] ?? null;
return is_string($name) && $name === $templateDisplayName;
}));
if ($byName !== []) {
$candidates = $byName;
}
}
return $candidates;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function extractString(array $payload, array $keys): ?string
{
$normalized = array_map('strtolower', $keys);
foreach ($payload as $key => $value) {
if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) {
continue;
}
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return null;
}
/**
* @param array<string, mixed> $graphOptions
*/
private function tenantKey(Tenant $tenant, array $graphOptions): string
{
$tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
return (string) $tenantId;
}
}

View File

@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
$resultWarnings = []; $resultWarnings = [];
$status = 'success'; $status = 'success';
$settingsTable = null; $settingsTable = null;
$usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
$fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot);
$validation = $this->validator->validate($snapshot); $validation = $this->validator->validate($snapshot);
$resultWarnings = array_merge($resultWarnings, $validation['warnings']); $resultWarnings = array_merge($resultWarnings, $validation['warnings']);
@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
} }
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
if ($policyType === 'settingsCatalogPolicy') { if ($usesSettingsCatalogTable) {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); $normalized = $this->buildSettingsCatalogSettingsTable(
$snapshot['settings'],
fallbackCategoryName: $fallbackCategoryName
);
$settingsTable = $normalized['table']; $settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']); $resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else { } else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
} }
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
if ($policyType === 'settingsCatalogPolicy') { if ($usesSettingsCatalogTable) {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); $normalized = $this->buildSettingsCatalogSettingsTable(
$snapshot['settingsDelta'],
'Settings delta',
$fallbackCategoryName
);
$settingsTable = $normalized['table']; $settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']); $resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else { } else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
} }
} elseif ($policyType === 'settingsCatalogPolicy') { } elseif ($usesSettingsCatalogTable) {
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; $resultWarnings[] = 'Settings not hydrated for this Configuration Policy.';
} }
$settings[] = $this->normalizeStandard($snapshot); $settings[] = $this->normalizeStandard($snapshot);
@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett
]; ];
} }
private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string
{
$templateReference = $snapshot['templateReference'] ?? null;
if (is_string($templateReference)) {
$decoded = json_decode($templateReference, true);
$templateReference = is_array($decoded) ? $decoded : null;
}
if (! is_array($templateReference)) {
return null;
}
$displayName = $templateReference['templateDisplayName'] ?? null;
if (is_string($displayName) && $displayName !== '') {
return $displayName;
}
$family = $templateReference['templateFamily'] ?? null;
if (is_string($family) && $family !== '') {
return Str::headline($family);
}
return null;
}
/** /**
* @param array<int, mixed> $settings * @param array<int, mixed> $settings
* @return array{table: array<string, mixed>, warnings: array<int, string>} * @return array{table: array<string, mixed>, warnings: array<int, string>}
*/ */
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array
{ {
$flattened = $this->flattenSettingsCatalogSettingInstances($settings); $flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName);
return [ return [
'table' => [ 'table' => [
@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl
* @param array<int, mixed> $settings * @param array<int, mixed> $settings
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>} * @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
*/ */
private function flattenSettingsCatalogSettingInstances(array $settings): array private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array
{ {
$rows = []; $rows = [];
$warnings = []; $warnings = [];
@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
&$warnedRowLimit, &$warnedRowLimit,
$definitions, $definitions,
$categories, $categories,
$defaultCategoryName $defaultCategoryName,
$fallbackCategoryName,
): void { ): void {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
if (! $warnedRowLimit) { if (! $warnedRowLimit) {
@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array
$categoryName = $defaultCategoryName; $categoryName = $defaultCategoryName;
} }
if (
$categoryName === '-'
&& is_string($fallbackCategoryName)
&& $fallbackCategoryName !== ''
&& is_array($definition)
&& ($definition['isFallback'] ?? false)
) {
$categoryName = $fallbackCategoryName;
}
// Convert technical type to user-friendly data type // Convert technical type to user-friendly data type
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
$type = $instance['@odata.type'] ?? null; $type = $instance['@odata.type'] ?? null;
$type = is_string($type) ? $type : ''; $type = is_string($type) ? $type : '';
if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) {
$collection = $instance['choiceSettingCollectionValue'] ?? null;
if (! is_array($collection) || $collection === []) {
return [];
}
$values = [];
foreach ($collection as $item) {
if (! is_array($item)) {
continue;
}
$value = $item['value'] ?? null;
if (is_string($value) && $value !== '') {
$values[] = $value;
}
}
return array_values(array_unique($values));
}
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
$simple = $instance['simpleSettingValue'] ?? null; $simple = $instance['simpleSettingValue'] ?? null;
if (is_array($simple)) { if (is_array($simple)) {
return $simple['value'] ?? $simple; $simpleValue = $simple['value'] ?? $simple;
if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) {
return $simpleValue['value'];
}
return $simpleValue;
} }
return $simple; return $simple;
@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance):
$choice = $instance['choiceSettingValue'] ?? null; $choice = $instance['choiceSettingValue'] ?? null;
if (is_array($choice)) { if (is_array($choice)) {
return $choice['value'] ?? $choice; $choiceValue = $choice['value'] ?? $choice;
if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) {
return $choiceValue['value'];
}
return $choiceValue;
} }
return $choice; return $choice;
@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string
if (is_string($value)) { if (is_string($value)) {
// Remove {tenantid} placeholder // Remove {tenantid} placeholder
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
$value = preg_replace('/\{[^}]+\}/', '', $value);
$value = preg_replace('/_+/', '_', $value); $value = preg_replace('/_+/', '_', $value);
// Extract choice label from choice values (last meaningful part) // Extract choice label from choice values (last meaningful part)
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0"
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { if (
str_contains($value, 'device_vendor_msft')
|| str_contains($value, 'user_vendor_msft')
|| str_contains($value, 'vendor_msft')
|| str_contains($value, '#microsoft.graph')
) {
$parts = explode('_', $value); $parts = explode('_', $value);
$lastPart = end($parts); $lastPart = end($parts);
@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
} }
$commonLastPartMapping = [
'in' => 'Inbound',
'out' => 'Outbound',
'allow' => 'Allow',
'block' => 'Block',
'tcp' => 'TCP',
'udp' => 'UDP',
'icmpv4' => 'ICMPv4',
'icmpv6' => 'ICMPv6',
'any' => 'Any',
'notconfigured' => 'Not configured',
'lan' => 'LAN',
'wireless' => 'Wireless',
'remoteaccess' => 'Remote access',
'domain' => 'Domain',
'private' => 'Private',
'public' => 'Public',
];
if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) {
return $commonLastPartMapping[strtolower($lastPart)];
}
// If last part is just a number, take second-to-last too // If last part is just a number, take second-to-last too
if (is_numeric($lastPart) && count($parts) > 1) { if (is_numeric($lastPart) && count($parts) > 1) {
$secondLast = $parts[count($parts) - 2]; $secondLast = $parts[count($parts) - 2];
@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string
} }
if (is_array($value)) { if (is_array($value)) {
if ($value === []) {
return '-';
}
if (array_is_list($value)) {
$parts = [];
foreach ($value as $item) {
if ($item === null) {
continue;
}
if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) {
$parts = [];
break;
}
$parts[] = $this->formatSettingsCatalogValue($item);
}
$parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-')));
if ($parts !== []) {
return implode(', ', $parts);
}
}
return json_encode($value); return json_encode($value);
} }

View File

@ -0,0 +1,609 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class EnrollmentAutopilotPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
public function supports(string $policyType): bool
{
return in_array($policyType, [
'windowsAutopilotDeploymentProfile',
'windowsEnrollmentStatusPage',
'enrollmentRestriction',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
], true);
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = is_array($snapshot) ? $snapshot : [];
$displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name');
$description = Arr::get($snapshot, 'description');
$warnings = [];
if ($policyType === 'enrollmentRestriction') {
$warnings[] = 'Restore is preview-only for Enrollment Restrictions.';
}
if ($policyType === 'deviceEnrollmentLimitConfiguration') {
$warnings[] = 'Restore is preview-only for Enrollment Limits.';
}
if ($policyType === 'deviceEnrollmentPlatformRestrictionsConfiguration') {
$warnings[] = 'Restore is preview-only for Platform Restrictions.';
}
if ($policyType === 'deviceEnrollmentNotificationConfiguration') {
$warnings[] = 'Restore is preview-only for Enrollment Notifications.';
}
$generalEntries = [
['key' => 'Type', 'value' => $policyType],
];
if (is_string($displayName) && $displayName !== '') {
$generalEntries[] = ['key' => 'Display name', 'value' => $displayName];
}
if (is_string($description) && $description !== '') {
$generalEntries[] = ['key' => 'Description', 'value' => $description];
}
$odataType = Arr::get($snapshot, '@odata.type');
if (is_string($odataType) && $odataType !== '') {
$generalEntries[] = ['key' => '@odata.type', 'value' => $odataType];
}
$roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds');
if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) {
$generalEntries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)];
}
$settings = [
[
'type' => 'keyValue',
'title' => 'General',
'entries' => $generalEntries,
],
];
$typeBlock = match ($policyType) {
'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot),
'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot),
'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot),
'deviceEnrollmentLimitConfiguration' => $this->buildEnrollmentLimitBlock($snapshot),
'deviceEnrollmentPlatformRestrictionsConfiguration' => $this->buildEnrollmentPlatformRestrictionsBlock($snapshot),
'deviceEnrollmentNotificationConfiguration' => $this->buildEnrollmentNotificationBlock($snapshot),
default => null,
};
if ($typeBlock !== null) {
$settings[] = $typeBlock;
}
$settings = array_values(array_filter($settings));
return [
'status' => 'ok',
'settings' => $settings,
'warnings' => $warnings,
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildAutopilotBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'deviceNameTemplate' => 'Device name template',
'language' => 'Language',
'locale' => 'Locale',
'deploymentMode' => 'Deployment mode',
'deviceType' => 'Device type',
'enableWhiteGlove' => 'Pre-provisioning (White Glove)',
'hybridAzureADJoinSkipConnectivityCheck' => 'Skip Hybrid AAD connectivity check',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_bool($value)) {
$entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled'];
}
}
$oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings');
if (is_array($oobe) && $oobe !== []) {
$oobe = Arr::except($oobe, ['@odata.type']);
foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) {
$entries[] = $entry;
}
}
$assignments = Arr::get($snapshot, 'assignments');
if (is_array($assignments) && $assignments !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Autopilot profile',
'entries' => $entries,
];
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function expandOutOfBoxExperienceEntries(array $oobe): array
{
$knownKeys = [
'hideEULA' => 'Hide EULA',
'userType' => 'User type',
'hideEscapeLink' => 'Hide escape link',
'deviceUsageType' => 'Device usage type',
'hidePrivacySettings' => 'Hide privacy settings',
'skipKeyboardSelectionPage' => 'Skip keyboard selection page',
'skipExpressSettings' => 'Skip express settings',
];
$entries = [];
foreach ($knownKeys as $key => $label) {
if (! array_key_exists($key, $oobe)) {
continue;
}
$value = $oobe[$key];
if (is_bool($value)) {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled'];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
} elseif (is_int($value) || is_float($value)) {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
}
unset($oobe[$key]);
}
foreach ($oobe as $key => $value) {
$label = Str::headline((string) $key);
if (is_bool($value)) {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled'];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
} elseif (is_int($value) || is_float($value)) {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
} elseif (is_array($value) && $value !== []) {
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
}
}
return $entries;
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentStatusPageBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'showInstallationProgress' => 'Show installation progress',
'blockDeviceSetupRetryByUser' => 'Block retry by user',
'allowDeviceResetOnInstallFailure' => 'Allow device reset on install failure',
'installProgressTimeoutInMinutes' => 'Install progress timeout (minutes)',
'allowLogCollectionOnInstallFailure' => 'Allow log collection on failure',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_int($value) || is_float($value)) {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_bool($value)) {
$entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled'];
}
}
$selected = Arr::get($snapshot, 'selectedMobileAppIds');
if (is_array($selected) && $selected !== []) {
$entries[] = ['key' => 'Selected mobile app IDs', 'value' => array_values($selected)];
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Enrollment Status Page (ESP)',
'entries' => $entries,
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentRestrictionBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'deviceEnrollmentConfigurationType' => 'Configuration type',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_int($value) || is_float($value)) {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
}
}
$platformRestrictions = Arr::get($snapshot, 'platformRestrictions');
$platformRestriction = Arr::get($snapshot, 'platformRestriction');
$platformPayload = is_array($platformRestrictions) && $platformRestrictions !== []
? $platformRestrictions
: (is_array($platformRestriction) ? $platformRestriction : null);
if (is_array($platformPayload) && $platformPayload !== []) {
$platformPayload = Arr::except($platformPayload, ['@odata.type']);
$platformBlocked = Arr::get($platformPayload, 'platformBlocked');
if (is_bool($platformBlocked)) {
$entries[] = ['key' => 'Platform blocked', 'value' => $platformBlocked ? 'Enabled' : 'Disabled'];
}
$personalBlocked = Arr::get($platformPayload, 'personalDeviceEnrollmentBlocked');
if (is_bool($personalBlocked)) {
$entries[] = ['key' => 'Personal device enrollment blocked', 'value' => $personalBlocked ? 'Enabled' : 'Disabled'];
}
$osMin = Arr::get($platformPayload, 'osMinimumVersion');
$entries[] = [
'key' => 'OS minimum version',
'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None',
];
$osMax = Arr::get($platformPayload, 'osMaximumVersion');
$entries[] = [
'key' => 'OS maximum version',
'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None',
];
$blockedManufacturers = Arr::get($platformPayload, 'blockedManufacturers');
$entries[] = [
'key' => 'Blocked manufacturers',
'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== [])
? array_values($blockedManufacturers)
: ['None'],
];
$blockedSkus = Arr::get($platformPayload, 'blockedSkus');
$entries[] = [
'key' => 'Blocked SKUs',
'value' => (is_array($blockedSkus) && $blockedSkus !== [])
? array_values($blockedSkus)
: ['None'],
];
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Enrollment restrictions',
'entries' => $entries,
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentLimitBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'deviceEnrollmentConfigurationType' => 'Configuration type',
'limit' => 'Enrollment limit',
'limitType' => 'Limit type',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_int($value) || is_float($value)) {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_bool($value)) {
$entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled'];
}
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Enrollment limits',
'entries' => $entries,
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentPlatformRestrictionsBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'platformType' => 'Platform type',
'deviceEnrollmentConfigurationType' => 'Configuration type',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_int($value) || is_float($value)) {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
}
}
$platformPayload = Arr::get($snapshot, 'platformRestrictions') ?? Arr::get($snapshot, 'platformRestriction');
if (is_array($platformPayload) && $platformPayload !== []) {
$prefix = (string) (Arr::get($snapshot, 'platformType') ?: 'Platform');
$this->appendPlatformRestrictionEntries($entries, $prefix, $platformPayload);
}
$typedRestrictions = [
'androidForWorkRestriction' => 'Android work profile',
'androidRestriction' => 'Android',
'iosRestriction' => 'iOS/iPadOS',
'macRestriction' => 'macOS',
'windowsRestriction' => 'Windows',
];
foreach ($typedRestrictions as $key => $prefix) {
$restriction = Arr::get($snapshot, $key);
if (! is_array($restriction) || $restriction === []) {
continue;
}
$this->appendPlatformRestrictionEntries($entries, $prefix, $restriction);
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Platform restrictions (enrollment)',
'entries' => $entries,
];
}
/**
* @param array<int, array{key: string, value: mixed}> $entries
*/
private function appendPlatformRestrictionEntries(array &$entries, string $prefix, array $payload): void
{
$payload = Arr::except($payload, ['@odata.type']);
$platformBlocked = Arr::get($payload, 'platformBlocked');
if (is_bool($platformBlocked)) {
$entries[] = ['key' => "{$prefix}: Platform blocked", 'value' => $platformBlocked ? 'Enabled' : 'Disabled'];
}
$personalBlocked = Arr::get($payload, 'personalDeviceEnrollmentBlocked');
if (is_bool($personalBlocked)) {
$entries[] = ['key' => "{$prefix}: Personal device enrollment blocked", 'value' => $personalBlocked ? 'Enabled' : 'Disabled'];
}
$osMin = Arr::get($payload, 'osMinimumVersion');
$entries[] = [
'key' => "{$prefix}: OS minimum version",
'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None',
];
$osMax = Arr::get($payload, 'osMaximumVersion');
$entries[] = [
'key' => "{$prefix}: OS maximum version",
'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None',
];
$blockedManufacturers = Arr::get($payload, 'blockedManufacturers');
$entries[] = [
'key' => "{$prefix}: Blocked manufacturers",
'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== [])
? array_values($blockedManufacturers)
: ['None'],
];
$blockedSkus = Arr::get($payload, 'blockedSkus');
$entries[] = [
'key' => "{$prefix}: Blocked SKUs",
'value' => (is_array($blockedSkus) && $blockedSkus !== [])
? array_values($blockedSkus)
: ['None'],
];
}
/**
* @return array{type: string, title: string, entries: array<int, array{key: string, value: mixed}>}|null
*/
private function buildEnrollmentNotificationBlock(array $snapshot): ?array
{
$entries = [];
foreach ([
'priority' => 'Priority',
'version' => 'Version',
'platformType' => 'Platform type',
'deviceEnrollmentConfigurationType' => 'Configuration type',
'brandingOptions' => 'Branding options',
'templateType' => 'Template type',
'defaultLocale' => 'Default locale',
'notificationMessageTemplateId' => 'Notification message template ID',
] as $key => $label) {
$value = Arr::get($snapshot, $key);
if (is_int($value) || is_float($value)) {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_string($value) && $value !== '') {
$entries[] = ['key' => $label, 'value' => $value];
} elseif (is_bool($value)) {
$entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled'];
}
}
$notificationTemplates = Arr::get($snapshot, 'notificationTemplates');
if (is_array($notificationTemplates) && $notificationTemplates !== []) {
$entries[] = ['key' => 'Notification templates', 'value' => array_values($notificationTemplates)];
}
$templateSnapshots = Arr::get($snapshot, 'notificationTemplateSnapshots');
if (is_array($templateSnapshots) && $templateSnapshots !== []) {
foreach ($templateSnapshots as $templateSnapshot) {
if (! is_array($templateSnapshot)) {
continue;
}
$channel = Arr::get($templateSnapshot, 'channel');
$channelLabel = is_string($channel) && $channel !== '' ? $channel : 'Template';
$templateId = Arr::get($templateSnapshot, 'template_id');
if (is_string($templateId) && $templateId !== '') {
$entries[] = ['key' => "{$channelLabel} template ID", 'value' => $templateId];
}
$template = Arr::get($templateSnapshot, 'template');
if (is_array($template) && $template !== []) {
$displayName = Arr::get($template, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => "{$channelLabel} template name", 'value' => $displayName];
}
$brandingOptions = Arr::get($template, 'brandingOptions');
if (is_string($brandingOptions) && $brandingOptions !== '') {
$entries[] = ['key' => "{$channelLabel} branding options", 'value' => $brandingOptions];
}
$defaultLocale = Arr::get($template, 'defaultLocale');
if (is_string($defaultLocale) && $defaultLocale !== '') {
$entries[] = ['key' => "{$channelLabel} default locale", 'value' => $defaultLocale];
}
}
$localizedMessages = Arr::get($templateSnapshot, 'localized_notification_messages');
if (is_array($localizedMessages) && $localizedMessages !== []) {
foreach ($localizedMessages as $localizedMessage) {
if (! is_array($localizedMessage)) {
continue;
}
$locale = Arr::get($localizedMessage, 'locale');
$localeLabel = is_string($locale) && $locale !== '' ? $locale : 'locale';
$subject = Arr::get($localizedMessage, 'subject');
if (is_string($subject) && $subject !== '') {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Subject", 'value' => $subject];
}
$messageTemplate = Arr::get($localizedMessage, 'messageTemplate');
if (is_string($messageTemplate) && $messageTemplate !== '') {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Message", 'value' => $messageTemplate];
}
$isDefault = Arr::get($localizedMessage, 'isDefault');
if (is_bool($isDefault)) {
$entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Default", 'value' => $isDefault ? 'Enabled' : 'Disabled'];
}
}
}
}
}
$assigned = Arr::get($snapshot, 'assignments');
if (is_array($assigned) && $assigned !== []) {
$entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]'];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Enrollment notifications',
'entries' => $entries,
];
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class ManagedDeviceAppConfigurationNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'managedDeviceAppConfiguration';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$normalized['settings'] = array_values(array_filter(
$normalized['settings'],
static function (array $block): bool {
$title = strtolower((string) ($block['title'] ?? ''));
return $title !== 'settings' && $title !== 'settings delta';
}
));
$rows = $this->buildSettingsRows($snapshot['settings'] ?? null);
if ($rows !== []) {
$normalized['settings'][] = [
'type' => 'table',
'title' => 'App configuration settings',
'rows' => $rows,
];
} else {
$normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.';
$normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== '')));
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildSettingsRows(mixed $settings): array
{
if (! is_array($settings) || $settings === []) {
return [];
}
$rows = [];
foreach ($settings as $setting) {
if (! is_array($setting)) {
continue;
}
$key = $setting['appConfigKey'] ?? null;
$rawValue = $setting['appConfigKeyValue'] ?? null;
$type = $setting['appConfigKeyType'] ?? null;
if (! is_string($key) || $key === '') {
continue;
}
$value = $this->normalizeValue($rawValue, $type);
$rows[] = [
'path' => $key,
'label' => $key,
'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'description' => is_string($type) && $type !== '' ? Str::headline($type) : null,
];
}
return $rows;
}
private function normalizeValue(mixed $value, mixed $type): mixed
{
$type = is_string($type) ? strtolower($type) : '';
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return $value;
}
if (is_string($value)) {
$trimmed = trim($value);
if ($type !== '' && str_contains($type, 'boolean')) {
if (in_array(strtolower($trimmed), ['true', 'false'], true)) {
return strtolower($trimmed) === 'true';
}
if (in_array(strtolower($trimmed), ['yes', 'no'], true)) {
return strtolower($trimmed) === 'yes';
}
if (in_array($trimmed, ['1', '0'], true)) {
return $trimmed === '1';
}
}
if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) {
if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) {
return (int) $trimmed;
}
}
return $trimmed;
}
return $value;
}
}

View File

@ -47,13 +47,21 @@ public function capture(
$snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy);
if (isset($snapshot['failure'])) { if (isset($snapshot['failure'])) {
throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); return [
'failure' => $snapshot['failure'],
];
} }
$payload = $snapshot['payload']; $payload = $snapshot['payload'];
$assignments = null; $assignments = null;
$scopeTags = null; $scopeTags = null;
$captureMetadata = []; $captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
if ($snapshotWarnings !== []) {
$existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : [];
$captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
}
// 2. Fetch assignments if requested // 2. Fetch assignments if requested
if ($includeAssignments) { if ($includeAssignments) {
@ -179,9 +187,9 @@ public function capture(
// 5. Create new PolicyVersion with all captured data // 5. Create new PolicyVersion with all captured data
$metadata = array_merge( $metadata = array_merge(
['source' => 'orchestrated_capture'], ['capture_source' => 'orchestrated_capture'],
$metadata, $metadata,
$captureMetadata $captureMetadata,
); );
$version = $this->versionService->captureVersion( $version = $this->versionService->captureVersion(

View File

@ -8,6 +8,7 @@
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger; use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Throwable; use Throwable;
@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context); $mapped = GraphErrorMapper::fromThrowable($throwable, $context);
// For certain policy types experiencing upstream Graph issues, fall back to metadata-only
if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) {
return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status);
}
return [ return [
'failure' => [ 'failure' => [
'policy_id' => $policy->id, 'policy_id' => $policy->id,
@ -77,8 +83,19 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
$metadata = Arr::except($response->data, ['payload']); $metadata = Arr::except($response->data, ['payload']);
$metadataWarnings = $metadata['warnings'] ?? []; $metadataWarnings = $metadata['warnings'] ?? [];
if ($policy->policy_type === 'settingsCatalogPolicy') { if ($policy->policy_type === 'windowsUpdateRing') {
[$payload, $metadata] = $this->hydrateSettingsCatalog( [$payload, $metadata] = $this->hydrateWindowsUpdateRing(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata,
);
}
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
policyType: $policy->policy_type,
tenantIdentifier: $tenantIdentifier, tenantIdentifier: $tenantIdentifier,
tenant: $tenant, tenant: $tenant,
policyId: $policy->external_id, policyId: $policy->external_id,
@ -107,8 +124,22 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
); );
} }
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
}
if ($response->failed()) { if ($response->failed()) {
$reason = $response->warnings[0] ?? 'Graph request failed'; $reason = $this->formatGraphFailureReason($response);
if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) {
return $this->createMetadataOnlySnapshot($policy, $reason, $response->status);
}
$failure = [ $failure = [
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'reason' => $reason, 'reason' => $reason,
@ -152,6 +183,98 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
]; ];
} }
private function formatGraphFailureReason(GraphResponse $response): string
{
$code = $response->meta['error_code']
?? ($response->errors[0]['code'] ?? null)
?? ($response->data['error']['code'] ?? null);
$message = $response->meta['error_message']
?? ($response->errors[0]['message'] ?? null)
?? ($response->data['error']['message'] ?? null)
?? ($response->warnings[0] ?? null);
$reason = 'Graph request failed';
if (is_string($message) && $message !== '') {
$reason = $message;
}
if (is_string($code) && $code !== '') {
$reason = sprintf('%s: %s', $code, $reason);
}
$requestId = $response->meta['request_id'] ?? null;
$clientRequestId = $response->meta['client_request_id'] ?? null;
$suffixParts = [];
if (is_string($clientRequestId) && $clientRequestId !== '') {
$suffixParts[] = sprintf('client_request_id=%s', $clientRequestId);
}
if (is_string($requestId) && $requestId !== '') {
$suffixParts[] = sprintf('request_id=%s', $requestId);
}
if ($suffixParts !== []) {
$reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts));
}
return $reason;
}
/**
* Hydrate Windows Update Ring payload via derived type cast to capture
* windowsUpdateForBusinessConfiguration-specific properties.
*
* @return array{0:array,1:array}
*/
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$odataType = $payload['@odata.type'] ?? null;
$castSegment = $this->deriveTypeCastSegment($odataType);
if ($castSegment === null) {
$metadata['properties_hydration'] = 'skipped';
return [$payload, $metadata];
}
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
$response = $this->graphClient->request('GET', $castPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed() || ! is_array($response->data)) {
$metadata['properties_hydration'] = 'failed';
return [$payload, $metadata];
}
$metadata['properties_hydration'] = 'complete';
return [array_merge($payload, $response->data), $metadata];
}
private function deriveTypeCastSegment(mixed $odataType): ?string
{
if (! is_string($odataType) || $odataType === '') {
return null;
}
if (! str_starts_with($odataType, '#')) {
return null;
}
$segment = ltrim($odataType, '#');
return $segment !== '' ? $segment : null;
}
private function isMetadataOnlyPolicyType(string $policyType): bool private function isMetadataOnlyPolicyType(string $policyType): bool
{ {
foreach (config('tenantpilot.supported_policy_types', []) as $type) { foreach (config('tenantpilot.supported_policy_types', []) as $type) {
@ -202,14 +325,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload):
} }
/** /**
* Hydrate settings catalog policies with configuration settings subresource. * Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines).
* *
* @return array{0:array,1:array} * @return array{0:array,1:array}
*/ */
private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{ {
$strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy'); $strategy = $this->contracts->memberHydrationStrategy($policyType);
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId); $settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
if ($strategy !== 'subresource_settings' || ! $settingsPath) { if ($strategy !== 'subresource_settings' || ! $settingsPath) {
return [$payload, $metadata]; return [$payload, $metadata];
@ -493,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena
return [$payload, $metadata]; return [$payload, $metadata];
} }
/**
* Hydrate enrollment notifications with message template details.
*
* @return array{0:array,1:array}
*/
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
{
$existing = $payload['notificationTemplateSnapshots'] ?? null;
if (is_array($existing) && $existing !== []) {
$metadata['enrollment_notification_templates_hydration'] = 'embedded';
return [$payload, $metadata];
}
$templateRefs = $payload['notificationTemplates'] ?? null;
if (! is_array($templateRefs) || $templateRefs === []) {
$metadata['enrollment_notification_templates_hydration'] = 'none';
return [$payload, $metadata];
}
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$snapshots = [];
$failures = 0;
foreach ($templateRefs as $templateRef) {
if (! is_string($templateRef) || $templateRef === '') {
continue;
}
[$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef);
if ($templateId === null) {
$failures++;
continue;
}
$templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId));
$templateResponse = $this->graphClient->request('GET', $templatePath, $options);
if ($templateResponse->failed() || ! is_array($templateResponse->data)) {
$failures++;
continue;
}
$template = Arr::except($templateResponse->data, ['@odata.context']);
$messagesPath = sprintf(
'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages',
urlencode($templateId)
);
$messagesResponse = $this->graphClient->request('GET', $messagesPath, $options);
$messages = [];
if ($messagesResponse->failed()) {
$failures++;
} else {
$pageItems = $messagesResponse->data['value'] ?? [];
if (is_array($pageItems)) {
foreach ($pageItems as $message) {
if (is_array($message)) {
$messages[] = Arr::except($message, ['@odata.context']);
}
}
}
}
$snapshots[] = [
'channel' => $channel,
'template_id' => $templateId,
'template' => $template,
'localized_notification_messages' => $messages,
];
}
if ($snapshots === []) {
$metadata['enrollment_notification_templates_hydration'] = 'failed';
return [$payload, $metadata];
}
$payload['notificationTemplateSnapshots'] = $snapshots;
$metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete';
return [$payload, $metadata];
}
/**
* @return array{0:?string,1:?string}
*/
private function parseEnrollmentNotificationTemplateRef(string $templateRef): array
{
if (! str_contains($templateRef, '_')) {
return [null, $templateRef];
}
[$channel, $templateId] = explode('_', $templateRef, 2);
$channel = trim($channel);
$templateId = trim($templateId);
if ($templateId === '') {
return [$channel !== '' ? $channel : null, null];
}
return [$channel !== '' ? $channel : null, $templateId];
}
/** /**
* Extract all settingDefinitionId from settings array, including nested children. * Extract all settingDefinitionId from settings array, including nested children.
*/ */
@ -531,6 +774,69 @@ private function stripGraphBaseUrl(string $nextLink): string
return ltrim(substr($nextLink, strlen($base)), '/'); return ltrim(substr($nextLink, strlen($base)), '/');
} }
return ltrim($nextLink, '/'); return $nextLink;
}
/**
* Determine if we should fall back to metadata-only for this policy type and error.
*/
private function shouldFallbackToMetadata(string $policyType, ?int $status): bool
{
// Only fallback on 5xx server errors
if ($status === null || $status < 500 || $status >= 600) {
return false;
}
// Enable fallback for policy types experiencing upstream Graph issues
$fallbackTypes = [
'mamAppConfiguration',
'managedDeviceAppConfiguration',
];
return in_array($policyType, $fallbackTypes, true);
}
/**
* Create a metadata-only snapshot from the Policy model when Graph is unavailable.
*
* @return array{payload:array,metadata:array,warnings:array}
*/
private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array
{
$odataType = match ($policy->policy_type) {
'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration',
'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
default => '#microsoft.graph.'.$policy->policy_type,
};
$payload = [
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'@odata.type' => $odataType,
'createdDateTime' => $policy->created_at?->toIso8601String(),
'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(),
];
if ($policy->platform) {
$payload['platform'] = $policy->platform;
}
$metadata = [
'source' => 'metadata_only',
'original_failure' => $failureReason,
'original_status' => $status,
'warnings' => [
sprintf(
'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.',
$status ?? 'error'
),
],
];
return [
'payload' => $payload,
'metadata' => $metadata,
'warnings' => $metadata['warnings'],
];
} }
} }

View File

@ -3,6 +3,7 @@
namespace App\Services\Intune; namespace App\Services\Intune;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphErrorMapper;
@ -24,6 +25,19 @@ public function __construct(
* @return array<int> IDs of policies synced or created * @return array<int> IDs of policies synced or created
*/ */
public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array
{
$result = $this->syncPoliciesWithReport($tenant, $supportedTypes);
return $result['synced'];
}
/**
* Sync supported policies for a tenant from Microsoft Graph.
*
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}>|null $supportedTypes
* @return array{synced: array<int>, failures: array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>}
*/
public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array
{ {
if (! $tenant->isActive()) { if (! $tenant->isActive()) {
throw new \RuntimeException('Tenant is archived or inactive.'); throw new \RuntimeException('Tenant is archived or inactive.');
@ -31,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []); $types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
$synced = []; $synced = [];
$failures = [];
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
foreach ($types as $typeConfig) { foreach ($types as $typeConfig) {
@ -68,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
]); ]);
if ($response->failed()) { if ($response->failed()) {
$failures[] = [
'policy_type' => $policyType,
'status' => $response->status,
'errors' => $response->errors,
'meta' => $response->meta,
];
continue; continue;
} }
@ -78,6 +100,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
continue; continue;
} }
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
if ($canonicalPolicyType !== $policyType) {
continue;
}
if ($policyType === 'appProtectionPolicy') { if ($policyType === 'appProtectionPolicy') {
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
@ -96,15 +124,17 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
$policyPlatform = $platform ?? ($policyData['platform'] ?? null); $policyPlatform = $platform ?? ($policyData['platform'] ?? null);
$existingWithDifferentType = Policy::query() $this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
->where('tenant_id', $tenant->id) tenantId: $tenant->id,
->where('external_id', $externalId) externalId: $externalId,
->where('policy_type', '!=', $policyType) policyType: $policyType,
->exists(); );
if ($existingWithDifferentType) { $this->reclassifyConfigurationPoliciesIfNeeded(
continue; tenantId: $tenant->id,
} externalId: $externalId,
policyType: $policyType,
);
$policy = Policy::updateOrCreate( $policy = Policy::updateOrCreate(
[ [
@ -125,7 +155,282 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
} }
} }
return $synced; return [
'synced' => $synced,
'failures' => $failures,
];
}
private function resolveCanonicalPolicyType(string $policyType, array $policyData): string
{
if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
return $this->resolveConfigurationPolicyType($policyData);
}
$enrollmentConfigurationTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentConfigurationTypes, true)) {
return $policyType;
}
if ($this->isEnrollmentStatusPageItem($policyData)) {
return 'windowsEnrollmentStatusPage';
}
if ($this->isEnrollmentNotificationItem($policyData)) {
return 'deviceEnrollmentNotificationConfiguration';
}
if ($this->isEnrollmentLimitItem($policyData)) {
return 'deviceEnrollmentLimitConfiguration';
}
if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) {
return 'deviceEnrollmentPlatformRestrictionsConfiguration';
}
return 'enrollmentRestriction';
}
private function resolveConfigurationPolicyType(array $policyData): string
{
if ($this->isSecurityBaselineConfigurationPolicy($policyData)) {
return 'securityBaselinePolicy';
}
if ($this->isEndpointSecurityConfigurationPolicy($policyData)) {
return 'endpointSecurityPolicy';
}
return 'settingsCatalogPolicy';
}
private function isEndpointSecurityConfigurationPolicy(array $policyData): bool
{
$technologies = $policyData['technologies'] ?? null;
if (is_string($technologies)) {
if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) {
return true;
}
}
if (is_array($technologies)) {
foreach ($technologies as $technology) {
if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) {
return true;
}
}
}
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'endpoint') !== false) {
return true;
}
}
return false;
}
private function isSecurityBaselineConfigurationPolicy(array $policyData): bool
{
$templateReference = $policyData['templateReference'] ?? null;
if (! is_array($templateReference)) {
return false;
}
$templateFamily = $templateReference['templateFamily'] ?? null;
if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) {
return true;
}
foreach ($templateReference as $value) {
if (is_string($value) && stripos($value, 'baseline') !== false) {
return true;
}
}
return false;
}
private function isEnrollmentStatusPageItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0)
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
}
private function isEnrollmentLimitItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0)
|| (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0);
}
private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && $odataType !== '') {
$odataTypeKey = strtolower($odataType);
if (in_array($odataTypeKey, [
'#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration',
'#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
if (is_string($configurationType) && $configurationType !== '') {
$configurationTypeKey = strtolower($configurationType);
if (in_array($configurationTypeKey, [
'deviceenrollmentplatformrestrictionconfiguration',
'deviceenrollmentplatformrestrictionsconfiguration',
], true)) {
return true;
}
}
return false;
}
private function isEnrollmentNotificationItem(array $policyData): bool
{
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) {
return true;
}
if (! is_string($configurationType) || $configurationType === '') {
return false;
}
return in_array(strtolower($configurationType), [
'enrollmentnotificationsconfiguration',
'deviceenrollmentnotificationconfiguration',
], true);
}
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$enrollmentTypes = [
'enrollmentRestriction',
'windowsEnrollmentStatusPage',
'deviceEnrollmentLimitConfiguration',
'deviceEnrollmentPlatformRestrictionsConfiguration',
'deviceEnrollmentNotificationConfiguration',
];
if (! in_array($policyType, $enrollmentTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$existingWrong->forceFill([
'policy_type' => $policyType,
])->save();
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
if (! in_array($policyType, $configurationTypes, true)) {
return;
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$existingWrong->forceFill([
'policy_type' => $policyType,
])->save();
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
} }
/** /**

View File

@ -16,6 +16,7 @@ class RestoreRiskChecker
{ {
public function __construct( public function __construct(
private readonly GroupResolver $groupResolver, private readonly GroupResolver $groupResolver,
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
) {} ) {}
/** /**
@ -38,7 +39,9 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem
$results = []; $results = [];
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
$results[] = $this->checkPreviewOnlyPolicies($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems);
$results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems);
$results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems);
$results[] = $this->checkStalePolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems);
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null); $results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
@ -228,6 +231,176 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
]; ];
} }
/**
* Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant.
*
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array
{
$issues = [];
$hasRestoreEnabled = false;
$graphOptions = $tenant->graphOptions();
foreach ($policyItems as $item) {
if ($item->policy_type !== 'endpointSecurityPolicy') {
continue;
}
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
$hasRestoreEnabled = true;
}
$payload = is_array($item->payload) ? $item->payload : [];
$templateReference = $payload['templateReference'] ?? null;
if (is_string($templateReference)) {
$decoded = json_decode($templateReference, true);
$templateReference = is_array($decoded) ? $decoded : null;
}
if (! is_array($templateReference)) {
$issues[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'label' => $item->resolvedDisplayName(),
'reason' => 'Missing templateReference in snapshot.',
];
continue;
}
$outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions);
if (! ($outcome['success'] ?? false)) {
$issues[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'label' => $item->resolvedDisplayName(),
'template_id' => $templateReference['templateId'] ?? null,
'template_family' => $templateReference['templateFamily'] ?? null,
'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.',
];
}
}
if ($issues === []) {
return [
'code' => 'endpoint_security_templates',
'severity' => 'safe',
'title' => 'Endpoint security templates',
'message' => 'All referenced Endpoint Security templates are available.',
'meta' => [
'count' => 0,
],
];
}
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
$message = $hasRestoreEnabled
? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.'
: 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).';
return [
'code' => 'endpoint_security_templates',
'severity' => $severity,
'title' => 'Endpoint security templates',
'message' => $message,
'meta' => [
'count' => count($issues),
'items' => $this->truncateList($issues, 10),
],
];
}
/**
* Detect snapshots that were captured as metadata-only.
*
* These snapshots cannot be safely restored because they do not contain the
* complete settings payload.
*
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
{
$affected = [];
$hasRestoreEnabled = false;
foreach ($policyItems as $item) {
if (! $this->isMetadataOnlySnapshot($item)) {
continue;
}
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
$hasRestoreEnabled = true;
}
$affected[] = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'policy_type' => $item->policy_type,
'label' => $item->resolvedDisplayName(),
'restore_mode' => $restoreMode,
];
}
if ($affected === []) {
return [
'code' => 'metadata_only',
'severity' => 'safe',
'title' => 'Snapshot completeness',
'message' => 'No metadata-only snapshots detected.',
'meta' => [
'count' => 0,
],
];
}
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
$message = $hasRestoreEnabled
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
return [
'code' => 'metadata_only',
'severity' => $severity,
'title' => 'Snapshot completeness',
'message' => $message,
'meta' => [
'count' => count($affected),
'items' => $this->truncateList($affected, 10),
],
];
}
private function isMetadataOnlySnapshot(BackupItem $item): bool
{
$metadata = is_array($item->metadata) ? $item->metadata : [];
$source = $metadata['source'] ?? null;
$snapshotSource = $metadata['snapshot_source'] ?? null;
if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') {
return true;
}
$warnings = $metadata['warnings'] ?? null;
if (is_array($warnings)) {
foreach ($warnings as $warning) {
if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) {
return true;
}
}
}
return false;
}
/** /**
* @param Collection<int, BackupItem> $policyItems * @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null * @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
@ -583,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string
{ {
$meta = $this->resolveTypeMeta($policyType); $meta = $this->resolveTypeMeta($policyType);
return (string) ($meta['restore'] ?? 'enabled'); if ($meta === []) {
return 'preview-only';
}
$restore = $meta['restore'] ?? 'enabled';
if (! is_string($restore) || $restore === '') {
return 'enabled';
}
return $restore;
} }
private function resolveTypeLabel(?string $policyType): string private function resolveTypeLabel(?string $policyType): string

View File

@ -27,6 +27,7 @@ public function __construct(
private readonly VersionService $versionService, private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator, private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
private readonly AssignmentRestoreService $assignmentRestoreService, private readonly AssignmentRestoreService $assignmentRestoreService,
private readonly FoundationMappingService $foundationMappingService, private readonly FoundationMappingService $foundationMappingService,
) {} ) {}
@ -151,6 +152,18 @@ public function executeFromPolicyVersion(
'version_captured_at' => $version->captured_at?->toIso8601String(), 'version_captured_at' => $version->captured_at?->toIso8601String(),
]; ];
$versionMetadata = is_array($version->metadata) ? $version->metadata : [];
$snapshotSource = $versionMetadata['source'] ?? null;
if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') {
$backupItemMetadata['snapshot_source'] = $snapshotSource;
}
$snapshotWarnings = $versionMetadata['warnings'] ?? null;
if (is_array($snapshotWarnings) && $snapshotWarnings !== []) {
$backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== '')));
}
if (is_array($scopeTagIds) && $scopeTagIds !== []) { if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds; $backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
} }
@ -418,12 +431,13 @@ public function execute(
$createdPolicyMode = null; $createdPolicyMode = null;
$settingsApplyEligible = false; $settingsApplyEligible = false;
if ($item->policy_type === 'settingsCatalogPolicy') { if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
$policyType = $item->policy_type;
$settings = $this->extractSettingsCatalogSettings($originalPayload); $settings = $this->extractSettingsCatalogSettings($originalPayload);
$policyPayload = $this->stripSettingsFromPayload($payload); $policyPayload = $this->stripSettingsFromPayload($payload);
$response = $this->graphClient->applyPolicy( $response = $this->graphClient->applyPolicy(
$item->policy_type, $policyType,
$item->policy_identifier, $item->policy_identifier,
$policyPayload, $policyPayload,
$graphOptions + ['method' => $updateMethod] $graphOptions + ['method' => $updateMethod]
@ -431,8 +445,19 @@ public function execute(
$settingsApplyEligible = $response->successful(); $settingsApplyEligible = $response->successful();
if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) {
if ($policyType === 'endpointSecurityPolicy') {
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
tenant: $tenant,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
);
}
$createOutcome = $this->createSettingsCatalogPolicy( $createOutcome = $this->createSettingsCatalogPolicy(
policyType: $policyType,
originalPayload: $originalPayload, originalPayload: $originalPayload,
settings: $settings, settings: $settings,
graphOptions: $graphOptions, graphOptions: $graphOptions,
@ -476,6 +501,7 @@ public function execute(
if ($settingsApplyEligible && $settings !== []) { if ($settingsApplyEligible && $settings !== []) {
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
policyType: $policyType,
policyId: $item->policy_identifier, policyId: $item->policy_identifier,
settings: $settings, settings: $settings,
graphOptions: $graphOptions, graphOptions: $graphOptions,
@ -484,7 +510,18 @@ public function execute(
if ($itemStatus === 'manual_required' && $settingsApply !== null if ($itemStatus === 'manual_required' && $settingsApply !== null
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
if ($policyType === 'endpointSecurityPolicy') {
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
tenant: $tenant,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
);
}
$createOutcome = $this->createSettingsCatalogPolicy( $createOutcome = $this->createSettingsCatalogPolicy(
policyType: $policyType,
originalPayload: $originalPayload, originalPayload: $originalPayload,
settings: $settings, settings: $settings,
graphOptions: $graphOptions, graphOptions: $graphOptions,
@ -527,14 +564,6 @@ public function execute(
]; ];
} }
} }
} elseif ($settingsApplyEligible && $settings !== []) {
$settingsApply = [
'total' => count($settings),
'applied' => 0,
'failed' => count($settings),
'manual_required' => 0,
'issues' => [],
];
} }
} else { } else {
if ($item->policy_type === 'appProtectionPolicy') { if ($item->policy_type === 'appProtectionPolicy') {
@ -555,6 +584,23 @@ public function execute(
$payload, $payload,
$graphOptions + ['method' => $updateMethod] $graphOptions + ['method' => $updateMethod]
); );
} elseif ($item->policy_type === 'windowsUpdateRing') {
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
$castSegment = $odataType && str_starts_with($odataType, '#')
? ltrim($odataType, '#')
: 'microsoft.graph.windowsUpdateForBusinessConfiguration';
$updatePath = sprintf(
'deviceManagement/deviceConfigurations/%s/%s',
urlencode($item->policy_identifier),
$castSegment,
);
$response = $this->graphClient->request(
$updateMethod,
$updatePath,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
} else { } else {
$response = $this->graphClient->applyPolicy( $response = $this->graphClient->applyPolicy(
$item->policy_type, $item->policy_type,
@ -630,6 +676,8 @@ public function execute(
'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null,
'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null,
'graph_client_request_id' => $response->meta['client_request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null,
'graph_method' => $response->meta['method'] ?? null,
'graph_path' => $response->meta['path'] ?? null,
]; ];
$hardFailures++; $hardFailures++;
@ -885,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array
private function resolveRestoreMode(string $policyType): string private function resolveRestoreMode(string $policyType): string
{ {
$meta = $this->resolveTypeMeta($policyType); $meta = $this->resolveTypeMeta($policyType);
if ($meta === []) {
return 'preview-only';
}
$restore = $meta['restore'] ?? 'enabled'; $restore = $meta['restore'] ?? 'enabled';
if (! is_string($restore) || $restore === '') { if (! is_string($restore) || $restore === '') {
@ -931,6 +984,10 @@ private function isNotFoundResponse(object $response): bool
$code = strtolower((string) ($response->meta['error_code'] ?? '')); $code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? ''));
if ($message !== '' && str_contains($message, 'resource not found for the segment')) {
return false;
}
if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) {
return true; return true;
} }
@ -1479,15 +1536,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string
* @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array<int,array<string,mixed>>}, 1: string} * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array<int,array<string,mixed>>}, 1: string}
*/ */
private function applySettingsCatalogPolicySettings( private function applySettingsCatalogPolicySettings(
string $policyType,
string $policyId, string $policyId,
array $settings, array $settings,
array $graphOptions, array $graphOptions,
array $context, array $context,
): array { ): array {
$method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); $method = $this->contracts->settingsWriteMethod($policyType);
$path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); $path = $this->contracts->settingsWritePath($policyType, $policyId);
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); $bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
$buildIssues = function (string $reason) use ($settings): array { $buildIssues = function (string $reason) use ($settings): array {
$issues = []; $issues = [];
@ -1520,7 +1578,7 @@ private function applySettingsCatalogPolicySettings(
]; ];
} }
$sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); $sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
if (! is_array($sanitized) || $sanitized === []) { if (! is_array($sanitized) || $sanitized === []) {
return [ return [
@ -1654,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
* @return array{success:bool,policy_id:?string,response:?object,mode:string} * @return array{success:bool,policy_id:?string,response:?object,mode:string}
*/ */
private function createSettingsCatalogPolicy( private function createSettingsCatalogPolicy(
string $policyType,
array $originalPayload, array $originalPayload,
array $settings, array $settings,
array $graphOptions, array $graphOptions,
array $context, array $context,
string $fallbackName, string $fallbackName,
): array { ): array {
$resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; $resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies';
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
if ($sanitizedSettings === []) { if ($sanitizedSettings === []) {
return [ return [
@ -1718,6 +1777,79 @@ private function createSettingsCatalogPolicy(
]; ];
} }
/**
* @param array<string, mixed> $originalPayload
* @param array<int, mixed> $settings
* @param array<string, mixed> $graphOptions
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function prepareEndpointSecurityPolicyForCreate(
Tenant $tenant,
array $originalPayload,
array $settings,
array $graphOptions,
array $context,
): array {
$templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']);
if (! is_array($templateReference)) {
throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.');
}
$templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions);
if (! ($templateOutcome['success'] ?? false)) {
$reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.';
throw new \RuntimeException($reason);
}
$resolvedTemplateId = $templateOutcome['template_id'] ?? null;
$resolvedReference = $templateOutcome['template_reference'] ?? $templateReference;
if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') {
throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).');
}
if (is_array($resolvedReference) && $resolvedReference !== []) {
$originalPayload['templateReference'] = $resolvedReference;
}
if ($settings === []) {
return $originalPayload;
}
$definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions);
if (! ($definitions['success'] ?? false)) {
return $originalPayload;
}
$templateDefinitionIds = $definitions['definition_ids'] ?? [];
if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) {
return $originalPayload;
}
$policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings);
$missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds));
if ($missing === []) {
return $originalPayload;
}
$sample = implode(', ', array_slice($missing, 0, 5));
$suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : '';
throw new \RuntimeException(sprintf(
'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s',
$resolvedTemplateId,
$sample,
$suffix,
));
}
/** /**
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object} * @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
*/ */

View File

@ -0,0 +1,274 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class ScriptsPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
public function supports(string $policyType): bool
{
return in_array($policyType, [
'deviceComplianceScript',
'deviceManagementScript',
'deviceShellScript',
'deviceHealthScript',
], true);
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = is_array($snapshot) ? $snapshot : [];
$displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name');
$description = Arr::get($snapshot, 'description');
$entries = [];
$entries[] = ['key' => 'Type', 'value' => $policyType];
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Display name', 'value' => $displayName];
}
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
$fileName = Arr::get($snapshot, 'fileName');
if (is_string($fileName) && $fileName !== '') {
$entries[] = ['key' => 'File name', 'value' => $fileName];
}
$publisher = Arr::get($snapshot, 'publisher');
if (is_string($publisher) && $publisher !== '') {
$entries[] = ['key' => 'Publisher', 'value' => $publisher];
}
$runAsAccount = Arr::get($snapshot, 'runAsAccount');
if (is_string($runAsAccount) && $runAsAccount !== '') {
$entries[] = ['key' => 'Run as account', 'value' => $runAsAccount];
}
$runAs32Bit = Arr::get($snapshot, 'runAs32Bit');
if (is_bool($runAs32Bit)) {
$entries[] = ['key' => 'Run as 32-bit', 'value' => $runAs32Bit ? 'Enabled' : 'Disabled'];
}
$enforceSignatureCheck = Arr::get($snapshot, 'enforceSignatureCheck');
if (is_bool($enforceSignatureCheck)) {
$entries[] = ['key' => 'Enforce signature check', 'value' => $enforceSignatureCheck ? 'Enabled' : 'Disabled'];
}
$entries = array_merge($entries, $this->contentEntries($snapshot));
$schedule = Arr::get($snapshot, 'runSchedule');
if (is_array($schedule) && $schedule !== []) {
$entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])];
}
$frequency = Arr::get($snapshot, 'runFrequency');
if (is_string($frequency) && $frequency !== '') {
$entries[] = ['key' => 'Run frequency', 'value' => $frequency];
}
$roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds');
if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) {
$entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)];
}
return [
'status' => 'ok',
'settings' => [
[
'type' => 'keyValue',
'title' => 'Script settings',
'entries' => $entries,
],
],
'warnings' => [],
];
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentEntries(array $snapshot): array
{
$showContent = (bool) config('tenantpilot.display.show_script_content', false);
$maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000);
if ($maxChars <= 0) {
$maxChars = 5000;
}
if (! $showContent) {
return $this->contentSummaryEntries($snapshot);
}
$entries = [];
$scriptContent = Arr::get($snapshot, 'scriptContent');
if (is_string($scriptContent) && $scriptContent !== '') {
$decoded = $this->decodeIfBase64Text($scriptContent);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $decoded;
}
}
if (! is_string($scriptContent) || $scriptContent === '') {
$scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64');
if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') {
$decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $this->normalizeDecodedText($decoded);
}
}
}
if (is_string($scriptContent) && $scriptContent !== '') {
$entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)];
}
foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) {
$value = Arr::get($snapshot, $key);
if (! is_string($value) || $value === '') {
continue;
}
$decoded = $this->decodeIfBase64Text($value);
if (is_string($decoded) && $decoded !== '') {
$value = $decoded;
}
$entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)];
}
return $entries;
}
private function decodeIfBase64Text(string $candidate): ?string
{
$trimmed = $this->stripWhitespace($candidate);
if ($trimmed === '' || strlen($trimmed) < 16) {
return null;
}
if (strlen($trimmed) % 4 !== 0) {
return null;
}
if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) {
return null;
}
$decoded = base64_decode($trimmed, true);
if (! is_string($decoded) || $decoded === '') {
return null;
}
$decoded = $this->normalizeDecodedText($decoded);
if ($decoded === '') {
return null;
}
if (! $this->looksLikeText($decoded)) {
return null;
}
return $decoded;
}
private function stripWhitespace(string $value): string
{
return preg_replace('/\s+/', '', $value) ?? '';
}
private function normalizeDecodedText(string $decoded): string
{
if (str_starts_with($decoded, "\xFF\xFE")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE');
} elseif (str_starts_with($decoded, "\xFE\xFF")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE');
} elseif (str_contains($decoded, "\x00")) {
$decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE');
}
if (str_starts_with($decoded, "\xEF\xBB\xBF")) {
$decoded = substr($decoded, 3);
}
return $decoded;
}
private function looksLikeText(string $decoded): bool
{
$length = strlen($decoded);
if ($length === 0) {
return false;
}
$nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0;
if ($nonPrintable > (int) max(1, $length * 0.05)) {
return false;
}
// Scripts should typically contain some whitespace or line breaks.
if ($length >= 24 && ! preg_match('/\s/', $decoded)) {
return false;
}
return true;
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentSummaryEntries(array $snapshot): array
{
// Script content and large blobs should not dominate normalized output.
// Keep only safe summary fields if present.
$contentKeys = [
'scriptContent',
'scriptContentBase64',
'detectionScriptContent',
'remediationScriptContent',
];
$entries = [];
foreach ($contentKeys as $key) {
$value = Arr::get($snapshot, $key);
if (is_string($value) && $value !== '') {
$entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))];
}
}
return $entries;
}
private function limitContent(string $content, int $maxChars): string
{
if (mb_strlen($content) <= $maxChars) {
return $content;
}
return mb_substr($content, 0, $maxChars).'…';
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
}

View File

@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name // Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
$cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId); $cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId);
// Remove other template placeholders, e.g. "{FirewallRuleId}"
$cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned);
// Clean up consecutive underscores // Clean up consecutive underscores
$cleaned = preg_replace('/_+/', '_', $cleaned); $cleaned = preg_replace('/_+/', '_', $cleaned);
$cleaned = trim($cleaned, '_'); $cleaned = trim($cleaned, '_');
$lowered = Str::lower($cleaned);
if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) {
$suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_');
if ($suffix === '') {
return 'Firewall rule';
}
$known = [
'displayname' => 'Name',
'name' => 'Name',
'description' => 'Description',
'direction' => 'Direction',
'action' => 'Action',
'actiontype' => 'Action type',
'profiles' => 'Profiles',
'profile' => 'Profile',
'protocol' => 'Protocol',
'localport' => 'Local port',
'remoteport' => 'Remote port',
'localaddress' => 'Local address',
'remoteaddress' => 'Remote address',
'interfacetype' => 'Interface type',
'interfacetypes' => 'Interface types',
'edgetraversal' => 'Edge traversal',
'enabled' => 'Enabled',
];
if (isset($known[$suffix])) {
return $known[$suffix];
}
return Str::headline($suffix);
}
// Convert to title case // Convert to title case
$prettified = Str::title(str_replace('_', ' ', $cleaned)); $prettified = Str::title(str_replace('_', ' ', $cleaned));

View File

@ -10,7 +10,7 @@ public function __construct(
public function supports(string $policyType): bool public function supports(string $policyType): bool
{ {
return $policyType === 'settingsCatalogPolicy'; return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
} }
/** /**

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class TermsAndConditionsNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'termsAndConditions';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = is_array($snapshot) ? $snapshot : [];
$entries = [];
$this->pushEntry($entries, 'Display name', Arr::get($snapshot, 'displayName'));
$this->pushEntry($entries, 'Title', Arr::get($snapshot, 'title'));
$this->pushEntry($entries, 'Description', Arr::get($snapshot, 'description'));
$this->pushEntry($entries, 'Acceptance statement', Arr::get($snapshot, 'acceptanceStatement'));
$this->pushEntry($entries, 'Body text', $this->limitText(Arr::get($snapshot, 'bodyText')));
$this->pushEntry($entries, 'Version', Arr::get($snapshot, 'version'));
$roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds');
if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) {
$this->pushEntry($entries, 'Scope tag IDs', array_values($roleScopeTagIds));
}
if ($entries === []) {
return [
'status' => 'warning',
'settings' => [],
'warnings' => ['Terms & Conditions snapshot contains no readable fields.'],
];
}
return [
'status' => 'ok',
'settings' => [
[
'type' => 'keyValue',
'title' => 'Terms & Conditions',
'entries' => $entries,
],
],
'warnings' => [],
];
}
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
/**
* @param array<int, array<string, mixed>> $entries
*/
private function pushEntry(array &$entries, string $key, mixed $value): void
{
if ($value === null) {
return;
}
if (is_string($value) && $value === '') {
return;
}
$entries[] = [
'key' => $key,
'value' => $value,
];
}
private function limitText(mixed $value): mixed
{
if (! is_string($value)) {
return $value;
}
return Str::limit($value, 1000);
}
}

View File

@ -85,6 +85,8 @@ public function captureFromGraph(
} }
$payload = $snapshot['payload']; $payload = $snapshot['payload'];
$snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : [];
$snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : [];
$assignments = null; $assignments = null;
$scopeTags = null; $scopeTags = null;
$assignmentMetadata = []; $assignmentMetadata = [];
@ -141,11 +143,17 @@ public function captureFromGraph(
} }
$metadata = array_merge( $metadata = array_merge(
['source' => 'version_capture'], $snapshotMetadata,
['capture_source' => 'version_capture'],
$metadata, $metadata,
$assignmentMetadata $assignmentMetadata,
); );
if ($snapshotWarnings !== []) {
$existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
$metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings)));
}
return $this->captureVersion( return $this->captureVersion(
policy: $policy, policy: $policy,
payload: $payload, payload: $payload,

View File

@ -0,0 +1,125 @@
<?php
namespace App\Services\Intune;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class WindowsDriverUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsDriverUpdateProfile';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$block = $this->buildDriverUpdateBlock($snapshot);
if ($block !== null) {
$normalized['settings'][] = $block;
$normalized['settings'] = array_values(array_filter($normalized['settings']));
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function buildDriverUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$approvalType = Arr::get($snapshot, 'approvalType');
if (is_string($approvalType) && $approvalType !== '') {
$entries[] = ['key' => 'Approval type', 'value' => $approvalType];
}
$deferral = Arr::get($snapshot, 'deploymentDeferralInDays');
if (is_int($deferral) || (is_numeric($deferral) && (string) (int) $deferral === (string) $deferral)) {
$entries[] = ['key' => 'Deployment deferral (days)', 'value' => (int) $deferral];
}
$deviceReporting = Arr::get($snapshot, 'deviceReporting');
if (is_int($deviceReporting) || (is_numeric($deviceReporting) && (string) (int) $deviceReporting === (string) $deviceReporting)) {
$entries[] = ['key' => 'Devices reporting', 'value' => (int) $deviceReporting];
}
$newUpdates = Arr::get($snapshot, 'newUpdates');
if (is_int($newUpdates) || (is_numeric($newUpdates) && (string) (int) $newUpdates === (string) $newUpdates)) {
$entries[] = ['key' => 'New driver updates', 'value' => (int) $newUpdates];
}
$inventorySyncStatus = Arr::get($snapshot, 'inventorySyncStatus');
if (is_array($inventorySyncStatus)) {
$state = Arr::get($inventorySyncStatus, 'driverInventorySyncState');
if (is_string($state) && $state !== '') {
$entries[] = ['key' => 'Inventory sync state', 'value' => $state];
}
$lastSuccessful = $this->formatDateTime(Arr::get($inventorySyncStatus, 'lastSuccessfulSyncDateTime'));
if ($lastSuccessful !== null) {
$entries[] = ['key' => 'Last successful inventory sync', 'value' => $lastSuccessful];
}
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Driver Update Profile',
'entries' => $entries,
];
}
private function formatDateTime(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return CarbonImmutable::parse($value)->toDateTimeString();
} catch (\Throwable) {
return $value;
}
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Services\Intune;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class WindowsFeatureUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsFeatureUpdateProfile';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$normalized['settings'][] = $this->buildFeatureUpdateBlock($snapshot);
$normalized['settings'] = array_values(array_filter($normalized['settings']));
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function buildFeatureUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$version = Arr::get($snapshot, 'featureUpdateVersion');
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Feature update version', 'value' => $version];
}
$rollout = Arr::get($snapshot, 'rolloutSettings');
if (is_array($rollout)) {
$start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null);
$end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null);
$interval = $rollout['offerIntervalInDays'] ?? null;
if ($start !== null) {
$entries[] = ['key' => 'Rollout start', 'value' => $start];
}
if ($end !== null) {
$entries[] = ['key' => 'Rollout end', 'value' => $end];
}
if ($interval !== null) {
$entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval];
}
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Feature Update Profile',
'entries' => $entries,
];
}
private function formatDateTime(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return CarbonImmutable::parse($value)->toDateTimeString();
} catch (\Throwable) {
return $value;
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class WindowsQualityUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsQualityUpdateProfile';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$block = $this->buildQualityUpdateBlock($snapshot);
if ($block !== null) {
$normalized['settings'][] = $block;
$normalized['settings'] = array_values(array_filter($normalized['settings']));
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function buildQualityUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$release = Arr::get($snapshot, 'releaseDateDisplayName');
if (is_string($release) && $release !== '') {
$entries[] = ['key' => 'Release', 'value' => $release];
}
$content = Arr::get($snapshot, 'deployableContentDisplayName');
if (is_string($content) && $content !== '') {
$entries[] = ['key' => 'Deployable content', 'value' => $content];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Quality Update Profile',
'entries' => $entries,
];
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class WindowsUpdateRingNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsUpdateRing';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$normalized['settings'] = array_values(array_filter(
$normalized['settings'],
fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general'
));
$normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot);
$normalized['settings'][] = $this->buildUserExperienceBlock($snapshot);
$normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot);
$normalized['settings'] = array_values(array_filter($normalized['settings']));
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function buildUpdateSettingsBlock(array $snapshot): ?array
{
$keys = [
'allowWindows11Upgrade',
'automaticUpdateMode',
'featureUpdatesDeferralPeriodInDays',
'featureUpdatesPaused',
'featureUpdatesPauseExpiryDateTime',
'qualityUpdatesDeferralPeriodInDays',
'qualityUpdatesPaused',
'qualityUpdatesPauseExpiryDateTime',
'updateWindowsDeviceDriverExclusion',
];
return $this->buildBlock('Update Settings', $snapshot, $keys);
}
private function buildUserExperienceBlock(array $snapshot): ?array
{
$keys = [
'deadlineForFeatureUpdatesInDays',
'deadlineForQualityUpdatesInDays',
'deadlineGracePeriodInDays',
'gracePeriodInDays',
'restartActiveHoursStart',
'restartActiveHoursEnd',
'setActiveHours',
'userPauseAccess',
'userCheckAccess',
];
return $this->buildBlock('User Experience', $snapshot, $keys);
}
private function buildAdvancedOptionsBlock(array $snapshot): ?array
{
$keys = [
'deliveryOptimizationMode',
'prereleaseFeatures',
'servicingChannel',
'microsoftUpdateServiceAllowed',
];
return $this->buildBlock('Advanced Options', $snapshot, $keys);
}
private function buildBlock(string $title, array $snapshot, array $keys): ?array
{
$entries = [];
foreach ($keys as $key) {
if (array_key_exists($key, $snapshot)) {
$entries[] = [
'key' => Str::headline($key),
'value' => $this->formatValue($snapshot[$key]),
];
}
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => $title,
'entries' => $entries,
];
}
private function formatValue(mixed $value): mixed
{
if (is_bool($value)) {
return $value ? 'Yes' : 'No';
}
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}

View File

@ -29,6 +29,14 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
], ],
'windowsFeatureUpdateProfile' => [
'windows' => '#microsoft.graph.windowsFeatureUpdateProfile',
'all' => '#microsoft.graph.windowsFeatureUpdateProfile',
],
'windowsQualityUpdateProfile' => [
'windows' => '#microsoft.graph.windowsQualityUpdateProfile',
'all' => '#microsoft.graph.windowsQualityUpdateProfile',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy', 'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy',
@ -54,9 +62,26 @@ protected static function odataTypeMap(): array
'windows' => '#microsoft.graph.deviceHealthScript', 'windows' => '#microsoft.graph.deviceHealthScript',
'all' => '#microsoft.graph.deviceHealthScript', 'all' => '#microsoft.graph.deviceHealthScript',
], ],
'termsAndConditions' => [
'windows' => '#microsoft.graph.termsAndConditions',
'all' => '#microsoft.graph.termsAndConditions',
],
'deviceComplianceScript' => [
'windows' => '#microsoft.graph.deviceComplianceScript',
'all' => '#microsoft.graph.deviceComplianceScript',
],
'enrollmentRestriction' => [ 'enrollmentRestriction' => [
'all' => '#microsoft.graph.deviceEnrollmentConfiguration', 'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
], ],
'deviceEnrollmentLimitConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration',
],
'deviceEnrollmentPlatformRestrictionsConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
],
'deviceEnrollmentNotificationConfiguration' => [
'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration',
],
'windowsAutopilotDeploymentProfile' => [ 'windowsAutopilotDeploymentProfile' => [
'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile', 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile',
], ],

View File

@ -0,0 +1,40 @@
<?php
namespace App\Support;
enum TenantRole: string
{
case Owner = 'owner';
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
public function canSync(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
public function canManageBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager => true,
default => false,
};
}
public function canRunBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
}

View File

@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"filament/filament": "^4.0", "filament/filament": "^4.0",
"lara-zeus/torch-filament": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"pepperfm/filament-json": "^4" "pepperfm/filament-json": "^4"

193
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", "content-hash": "20819254265bddd0aa70006919cb735f",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@ -2082,6 +2082,87 @@
}, },
"time": "2025-11-13T14:57:49+00:00" "time": "2025-11-13T14:57:49+00:00"
}, },
{
"name": "lara-zeus/torch-filament",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/lara-zeus/torch-filament.git",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009",
"shasum": ""
},
"require": {
"filament/filament": "^4.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.16",
"torchlight/engine": "^0.1.0"
},
"require-dev": {
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.0",
"nunomaduro/phpinsights": "^2.8",
"orchestra/testbench": "^8.0",
"phpstan/extension-installer": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaraZeus\\TorchFilament\\TorchFilamentServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaraZeus\\TorchFilament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lara Zeus",
"email": "info@larazeus.com"
}
],
"description": "Infolist component to highlight code using Torchlight Engine",
"homepage": "https://larazeus.com/torch-filament",
"keywords": [
"code",
"design",
"engine",
"filamentphp",
"highlight",
"input",
"lara-zeus",
"laravel",
"torchlight",
"ui"
],
"support": {
"issues": "https://github.com/lara-zeus/torch-filament/issues",
"source": "https://github.com/lara-zeus/torch-filament"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/larazeus",
"type": "custom"
},
{
"url": "https://github.com/atmonshi",
"type": "github"
}
],
"time": "2025-06-11T19:32:10+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.42.0", "version": "v12.42.0",
@ -4265,6 +4346,60 @@
}, },
"time": "2025-02-26T00:08:40+00:00" "time": "2025-02-26T00:08:40+00:00"
}, },
{
"name": "phiki/phiki",
"version": "v1.1.6",
"source": {
"type": "git",
"url": "https://github.com/phikiphp/phiki.git",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"php": "^8.2"
},
"require-dev": {
"illuminate/support": "^11.30",
"laravel/pint": "^1.18.1",
"pestphp/pest": "^3.5.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^7.1.6"
},
"bin": [
"bin/phiki"
],
"type": "library",
"autoload": {
"psr-4": {
"Phiki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Chandler",
"email": "support@ryangjchandler.co.uk",
"homepage": "https://ryangjchandler.co.uk",
"role": "Developer"
}
],
"description": "Syntax highlighting using TextMate grammars in PHP.",
"support": {
"issues": "https://github.com/phikiphp/phiki/issues",
"source": "https://github.com/phikiphp/phiki/tree/v1.1.6"
},
"time": "2025-06-06T20:18:29+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.4", "version": "1.9.4",
@ -8110,6 +8245,62 @@
}, },
"time": "2024-12-21T16:25:41+00:00" "time": "2024-12-21T16:25:41+00:00"
}, },
{
"name": "torchlight/engine",
"version": "v0.1.0",
"source": {
"type": "git",
"url": "https://github.com/torchlight-api/engine.git",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"phiki/phiki": "^1.1.4",
"php": "^8.2"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"laravel/pint": "^1.13",
"pestphp/pest": "^2"
},
"type": "library",
"autoload": {
"psr-4": {
"Torchlight\\Engine\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aaron Francis",
"email": "aaron@hammerstone.dev"
},
{
"name": "John Koster",
"email": "john@stillat.com"
}
],
"description": "The PHP-based Torchlight code annotation and rendering engine.",
"keywords": [
"Code highlighting",
"syntax highlighting"
],
"support": {
"issues": "https://github.com/torchlight-api/engine/issues",
"source": "https://github.com/torchlight-api/engine/tree/v0.1.0"
},
"time": "2025-04-02T01:47:48+00:00"
},
{ {
"name": "ueberdosis/tiptap-php", "name": "ueberdosis/tiptap-php",
"version": "2.0.0", "version": "2.0.0",

View File

@ -80,7 +80,7 @@
], ],
'settingsCatalogPolicy' => [ 'settingsCatalogPolicy' => [
'resource' => 'deviceManagement/configurationPolicies', 'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'],
'allowed_expand' => ['settings'], 'allowed_expand' => ['settings'],
'type_family' => [ 'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy', '#microsoft.graph.deviceManagementConfigurationPolicy',
@ -134,6 +134,96 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', 'scope_tag_field' => 'roleScopeTagIds',
], ],
'endpointSecurityPolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_whitelist' => [
'name',
'description',
],
'update_map' => [
'displayName' => 'name',
],
'update_strip_keys' => [
'platforms',
'technologies',
'templateReference',
'assignments',
],
'member_hydration_strategy' => 'subresource_settings',
'subresources' => [
'settings' => [
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
],
'settings_write' => [
'path_template' => 'deviceManagement/configurationPolicies/{id}/settings',
'method' => 'POST',
'bulk' => true,
'body_shape' => 'collection',
'fallback_body_shape' => 'wrapped',
],
// Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
// Scope Tags
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'securityBaselinePolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceManagementConfigurationPolicy',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'member_hydration_strategy' => 'subresource_settings',
'subresources' => [
'settings' => [
'path' => 'deviceManagement/configurationPolicies/{id}/settings',
'collection' => true,
'paging' => true,
'allowed_select' => [],
'allowed_expand' => [],
],
],
// Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
// Scope Tags
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'windowsUpdateRing' => [ 'windowsUpdateRing' => [
'resource' => 'deviceManagement/deviceConfigurations', 'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
@ -145,6 +235,13 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'update_strip_keys' => [
'version',
'qualityUpdatesPauseStartDate',
'featureUpdatesPauseStartDate',
'qualityUpdatesWillBeRolledBack',
'featureUpdatesWillBeRolledBack',
],
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
@ -155,6 +252,88 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', 'scope_tag_field' => 'roleScopeTagIds',
], ],
'windowsFeatureUpdateProfile' => [
'resource' => 'deviceManagement/windowsFeatureUpdateProfiles',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsFeatureUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'deployableContentDisplayName',
'endOfSupportDate',
],
'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'windowsQualityUpdateProfile' => [
'resource' => 'deviceManagement/windowsQualityUpdateProfiles',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsQualityUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'releaseDateDisplayName',
'deployableContentDisplayName',
],
'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'windowsDriverUpdateProfile' => [
'resource' => 'deviceManagement/windowsDriverUpdateProfiles',
'allowed_select' => [
'id',
'displayName',
'description',
'@odata.type',
'createdDateTime',
'lastModifiedDateTime',
'approvalType',
'deploymentDeferralInDays',
'roleScopeTagIds',
],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsDriverUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'deviceReporting',
'newUpdates',
'inventorySyncStatus',
],
'assignments_list_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
@ -215,6 +394,43 @@
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments', 'assignments_payload_key' => 'assignments',
], ],
'mamAppConfiguration' => [
'resource' => 'deviceAppManagement/targetedManagedAppConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.targetedManagedAppConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'managedDeviceAppConfiguration' => [
'resource' => 'deviceAppManagement/mobileAppConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.managedDeviceMobileAppConfiguration',
'#microsoft.graph.mobileAppConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'assignments',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'conditionalAccessPolicy' => [ 'conditionalAccessPolicy' => [
'resource' => 'identity/conditionalAccess/policies', 'resource' => 'identity/conditionalAccess/policies',
'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'], 'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'],
@ -227,6 +443,26 @@
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
], ],
'deviceComplianceScript' => [
'resource' => 'deviceManagement/deviceComplianceScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceComplianceScript',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceComplianceScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceHealthScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceManagementScript' => [ 'deviceManagementScript' => [
'resource' => 'deviceManagement/deviceManagementScripts', 'resource' => 'deviceManagement/deviceManagementScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
@ -287,13 +523,64 @@
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE', 'assignments_delete_method' => 'DELETE',
], ],
'deviceEnrollmentLimitConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'deviceEnrollmentPlatformRestrictionsConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'deviceEnrollmentNotificationConfiguration' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceEnrollmentNotificationConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'notificationTemplateSnapshots',
],
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'enrollmentRestriction' => [ 'enrollmentRestriction' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [], 'allowed_expand' => [],
'type_family' => [ 'type_family' => [
'#microsoft.graph.deviceEnrollmentConfiguration', '#microsoft.graph.deviceEnrollmentConfiguration',
'#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
'#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration', '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration',
'#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration', '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration',
], ],
@ -306,6 +593,48 @@
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments', 'assignments_payload_key' => 'enrollmentConfigurationAssignments',
], ],
'termsAndConditions' => [
'resource' => 'deviceManagement/termsAndConditions',
'allowed_select' => [
'id',
'displayName',
'description',
'title',
'bodyText',
'acceptanceStatement',
'version',
'roleScopeTagIds',
'lastModifiedDateTime',
'createdDateTime',
],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.termsAndConditions',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'createdDateTime',
'lastModifiedDateTime',
'modifiedDateTime',
'version',
'acceptanceStatuses',
'assignments',
'groupAssignments',
],
'assignments_list_path' => '/deviceManagement/termsAndConditions/{id}/assignments',
'assignments_create_path' => '/deviceManagement/termsAndConditions/{id}/assignments',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'termsAndConditionsAssignments',
'assignments_update_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'windowsAutopilotDeploymentProfile' => [ 'windowsAutopilotDeploymentProfile' => [
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
@ -360,6 +689,11 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'update_strip_keys' => [
'isAssigned',
'templateId',
'isMigratingToConfigurationPolicy',
],
], ],
'mobileApp' => [ 'mobileApp' => [
'resource' => 'deviceAppManagement/mobileApps', 'resource' => 'deviceAppManagement/mobileApps',

View File

@ -8,7 +8,7 @@
'category' => 'Configuration', 'category' => 'Configuration',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
@ -39,11 +39,41 @@
'category' => 'Update Management', 'category' => 'Update Management',
'platform' => 'windows', 'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium-high', 'risk' => 'medium-high',
], ],
[
'type' => 'windowsFeatureUpdateProfile',
'label' => 'Feature Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[
'type' => 'windowsQualityUpdateProfile',
'label' => 'Quality Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[
'type' => 'windowsDriverUpdateProfile',
'label' => 'Driver Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[ [
'type' => 'deviceCompliancePolicy', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',
@ -64,6 +94,27 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium-high', 'risk' => 'medium-high',
], ],
[
'type' => 'mamAppConfiguration',
'label' => 'App Configuration (MAM)',
'category' => 'Apps/MAM',
'platform' => 'mobile',
'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[
'type' => 'managedDeviceAppConfiguration',
'label' => 'App Configuration (Device)',
'category' => 'Apps/MAM',
'platform' => 'mobile',
'endpoint' => 'deviceAppManagement/mobileAppConfigurations',
'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[ [
'type' => 'conditionalAccessPolicy', 'type' => 'conditionalAccessPolicy',
'label' => 'Conditional Access', 'label' => 'Conditional Access',
@ -105,14 +156,14 @@
'risk' => 'medium', 'risk' => 'medium',
], ],
[ [
'type' => 'enrollmentRestriction', 'type' => 'deviceComplianceScript',
'label' => 'Enrollment Restrictions', 'label' => 'Custom Compliance Scripts',
'category' => 'Enrollment', 'category' => 'Compliance',
'platform' => 'all', 'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceComplianceScripts',
'backup' => 'full', 'backup' => 'full',
'restore' => 'preview-only', 'restore' => 'enabled',
'risk' => 'high', 'risk' => 'medium-high',
], ],
[ [
'type' => 'windowsAutopilotDeploymentProfile', 'type' => 'windowsAutopilotDeploymentProfile',
@ -130,11 +181,61 @@
'category' => 'Enrollment', 'category' => 'Enrollment',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
], ],
[
'type' => 'deviceEnrollmentLimitConfiguration',
'label' => 'Enrollment Limits',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration',
'label' => 'Platform Restrictions (Enrollment)',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'deviceEnrollmentNotificationConfiguration',
'label' => 'Enrollment Notifications',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'",
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'enrollmentRestriction',
'label' => 'Enrollment Restrictions',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[
'type' => 'termsAndConditions',
'label' => 'Terms & Conditions',
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/termsAndConditions',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[ [
'type' => 'endpointSecurityIntent', 'type' => 'endpointSecurityIntent',
'label' => 'Endpoint Security Intents', 'label' => 'Endpoint Security Intents',
@ -145,6 +246,26 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'high', 'risk' => 'high',
], ],
[
'type' => 'endpointSecurityPolicy',
'label' => 'Endpoint Security Policies',
'category' => 'Endpoint Security',
'platform' => 'windows',
'endpoint' => 'deviceManagement/configurationPolicies',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[
'type' => 'securityBaselinePolicy',
'label' => 'Security Baselines',
'category' => 'Endpoint Security',
'platform' => 'windows',
'endpoint' => 'deviceManagement/configurationPolicies',
'backup' => 'full',
'restore' => 'preview-only',
'risk' => 'high',
],
[ [
'type' => 'mobileApp', 'type' => 'mobileApp',
'label' => 'Applications (Metadata only)', 'label' => 'Applications (Metadata only)',
@ -198,4 +319,9 @@
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
], ],
'display' => [
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
],
]; ];

View File

@ -26,6 +26,7 @@ public function definition(): array
'app_status' => 'ok', 'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => 'active', 'status' => 'active',
'environment' => 'other',
'is_current' => false, 'is_current' => false,
'metadata' => [], 'metadata' => [],
]; ];

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('environment')->default('other')->after('status');
$table->index('environment');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropIndex(['environment']);
$table->dropColumn('environment');
});
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_user', function (Blueprint $table) {
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('owner');
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
});
$now = now();
$tenantIds = DB::table('tenants')
->whereNull('deleted_at')
->pluck('id');
$userIds = DB::table('users')->pluck('id');
if ($tenantIds->isEmpty() || $userIds->isEmpty()) {
return;
}
$rows = [];
foreach ($tenantIds as $tenantId) {
foreach ($userIds as $userId) {
$rows[] = [
'tenant_id' => $tenantId,
'user_id' => $userId,
'role' => 'owner',
'created_at' => $now,
'updated_at' => $now,
];
if (count($rows) >= 500) {
DB::table('tenant_user')->insertOrIgnore($rows);
$rows = [];
}
}
}
if ($rows !== []) {
DB::table('tenant_user')->insertOrIgnore($rows);
}
}
public function down(): void
{
Schema::dropIfExists('tenant_user');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_tenant_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->boolean('is_favorite')->default(false);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'tenant_id']);
$table->index(['user_id', 'last_used_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_tenant_preferences');
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backup_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name');
$table->boolean('is_enabled')->default(true);
$table->string('timezone')->default('UTC');
$table->enum('frequency', ['daily', 'weekly']);
$table->time('time_of_day');
$table->json('days_of_week')->nullable();
$table->json('policy_types');
$table->boolean('include_foundations')->default(true);
$table->integer('retention_keep_last')->default(30);
$table->dateTime('last_run_at')->nullable();
$table->string('last_run_status')->nullable();
$table->dateTime('next_run_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'is_enabled']);
$table->index('next_run_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_schedules');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backup_schedule_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->dateTime('scheduled_for');
$table->dateTime('started_at')->nullable();
$table->dateTime('finished_at')->nullable();
$table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']);
$table->json('summary')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
$table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete();
$table->timestamps();
$table->unique(['backup_schedule_id', 'scheduled_for']);
$table->index(['backup_schedule_id', 'scheduled_for']);
$table->index(['tenant_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_schedule_runs');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->after('tenant_id')
->constrained()
->nullOnDelete();
$table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table->dropIndex('backup_schedule_runs_user_created');
$table->dropConstrainedForeignId('user_id');
});
}
};

View File

@ -18,7 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>

View File

@ -31,7 +31,11 @@
<p>Admin consent wurde bestätigt.</p> <p>Admin consent wurde bestätigt.</p>
@endif @endif
<p><a href="{{ route('filament.admin.resources.tenants.view', $tenant) }}">Zurück zur Tenant-Detailseite</a></p> <p>
<a href="{{ route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]) }}">
Zurück zur Tenant-Detailseite
</a>
</p>
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,6 +1,7 @@
@php @php
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
$summary = $diff['summary'] ?? []; $summary = $diff['summary'] ?? [];
$policyType = $diff['policy_type'] ?? null;
$groupByBlock = static function (array $items): array { $groupByBlock = static function (array $items): array {
$groups = []; $groups = [];
@ -50,6 +51,180 @@
return is_string($value) && strlen($value) > 160; return is_string($value) && strlen($value) > 160;
}; };
$isScriptKey = static function (mixed $name): bool {
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
};
$canHighlightScripts = static function (?string $policyType): bool {
return (bool) config('tenantpilot.display.show_script_content', false)
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
};
$selectGrammar = static function (?string $policyType, string $code): string {
if ($policyType === 'deviceShellScript') {
$firstLine = strtok($code, "\n") ?: '';
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
return 'zsh';
}
if (str_contains($shebang, 'bash')) {
return 'bash';
}
return 'sh';
}
return 'sh';
}
return 'powershell';
};
$highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
try {
return (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
return null;
}
};
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
if ($code === '') {
return '';
}
try {
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: false,
);
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
return null;
}
return trim((string) ($matches[0] ?? ''));
} catch (\Throwable $e) {
return null;
}
};
$splitLines = static function (string $text): array {
$text = str_replace(["\r\n", "\r"], "\n", $text);
return $text === '' ? [] : explode("\n", $text);
};
$myersLineDiff = static function (array $a, array $b): array {
$n = count($a);
$m = count($b);
$max = $n + $m;
$v = [1 => 0];
$trace = [];
for ($d = 0; $d <= $max; $d++) {
$trace[$d] = $v;
for ($k = -$d; $k <= $d; $k += 2) {
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$x = $kPlus;
} else {
$x = $kMinus + 1;
}
$y = $x - $k;
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
$x++;
$y++;
}
$v[$k] = $x;
if ($x >= $n && $y >= $m) {
break 2;
}
}
}
$ops = [];
$x = $n;
$y = $m;
for ($d = count($trace) - 1; $d >= 0; $d--) {
$v = $trace[$d];
$k = $x - $y;
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$prevK = $k + 1;
} else {
$prevK = $k - 1;
}
$prevX = $v[$prevK] ?? 0;
$prevY = $prevX - $prevK;
while ($x > $prevX && $y > $prevY) {
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
$x--;
$y--;
}
if ($d === 0) {
break;
}
if ($x === $prevX) {
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
$y--;
} else {
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
$x--;
}
}
return array_reverse($ops);
};
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
};
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
@ -103,37 +278,467 @@
$to = $value['to']; $to = $value['to'];
$fromText = $stringify($from); $fromText = $stringify($from);
$toText = $stringify($to); $toText = $stringify($to);
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
$rows = [];
if ($isScriptContent) {
$count = count($ops);
for ($i = 0; $i < $count; $i++) {
$op = $ops[$i];
$next = $ops[$i + 1] ?? null;
$type = $op['type'] ?? null;
$line = (string) ($op['line'] ?? '');
if ($type === 'equal') {
$rows[] = [
'left' => ['type' => 'equal', 'line' => $line],
'right' => ['type' => 'equal', 'line' => $line],
];
continue;
}
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
];
$i++;
continue;
}
if ($type === 'delete') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'blank', 'line' => ''],
];
continue;
}
if ($type === 'insert') {
$rows[] = [
'left' => ['type' => 'blank', 'line' => ''],
'right' => ['type' => 'insert', 'line' => $line],
];
continue;
}
}
}
@endphp @endphp
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="text-sm font-medium text-gray-900 dark:text-white"> <div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }} {{ (string) $name }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span> @if ($isScriptContent)
@if ($isExpandable($from)) <div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
<details class="mt-1"> <span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
<details class="mt-1" x-data="{ fullscreenOpen: false }">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200"> <summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View View
</summary> </summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
Fullscreen
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
<div
x-show="fullscreenOpen"
x-cloak
x-on:keydown.escape.window="fullscreenOpen = false"
class="fixed inset-0 z-50"
>
<div class="absolute inset-0 bg-gray-950/50"></div>
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
<div class="flex items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
Close
</x-filament::button>
</div>
</div>
<div class="flex-1 overflow-hidden p-4">
<div
x-data="{
tab: 'diff',
syncing: false,
syncHorizontal: true,
sync(from, to) {
if (this.syncing) return;
this.syncing = true;
to.scrollTop = from.scrollTop;
const bothHorizontal = this.syncHorizontal
&& from.scrollWidth > from.clientWidth
&& to.scrollWidth > to.clientWidth;
if (bothHorizontal) {
to.scrollLeft = from.scrollLeft;
}
requestAnimationFrame(() => { this.syncing = false; });
},
}"
x-init="$nextTick(() => {
const left = $refs.left;
const right = $refs.right;
if (!left || !right) return;
left.addEventListener('scroll', () => sync(left, right), { passive: true });
right.addEventListener('scroll', () => sync(right, left), { passive: true });
})"
class="h-full space-y-3"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
</div>
</div>
</div>
</details> </details>
@else </div>
<div class="mt-1">{{ $fromText }}</div> @else
@endif <div class="text-sm text-gray-600 dark:text-gray-300">
</div> <span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
<div class="text-sm text-gray-600 dark:text-gray-300"> @if ($isExpandable($from))
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span> <details class="mt-1">
@if ($isExpandable($to)) <summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
<details class="mt-1"> View
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200"> </summary>
View <pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
</summary> </details>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre> @else
</details> <div class="mt-1">{{ $fromText }}</div>
@else @endif
<div class="mt-1">{{ $toText }}</div> </div>
@endif <div class="text-sm text-gray-600 dark:text-gray-300">
</div> <span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
@if ($isExpandable($to))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
</details>
@else
<div class="mt-1">{{ $toText }}</div>
@endif
</div>
@endif
</div> </div>
@else @else
@php @php
@ -149,7 +754,20 @@
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200"> <summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View View
</summary> </summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre> @php
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
@endphp
@if (is_string($highlighted) && $highlighted !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
@else
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
@endif
</details> </details>
@else @else
<div class="break-words">{{ $text }}</div> <div class="break-words">{{ $text }}</div>

View File

@ -1,4 +1,7 @@
@php @php
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
$general = $getState(); $general = $getState();
$entries = is_array($general) ? ($general['entries'] ?? []) : []; $entries = is_array($general) ? ($general['entries'] ?? []) : [];
$cards = []; $cards = [];
@ -61,6 +64,27 @@
'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200', 'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200',
'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200', 'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200',
]; ];
$formatIsoDateTime = static function (string $value): ?string {
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) {
return null;
}
// Graph can return 7 fractional digits; PHP supports 6 (microseconds).
$normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed);
try {
return CarbonImmutable::parse($normalized)->toDateTimeString();
} catch (\Throwable) {
return null;
}
};
@endphp @endphp
@if (empty($cards)) @if (empty($cards))
@ -72,6 +96,9 @@
$keyLower = $entry['key_lower'] ?? ''; $keyLower = $entry['key_lower'] ?? '';
$value = $entry['value'] ?? null; $value = $entry['value'] ?? null;
$isPlatform = str_contains($keyLower, 'platform'); $isPlatform = str_contains($keyLower, 'platform');
$isTechnologies = str_contains($keyLower, 'technolog');
$isTemplateReference = str_contains($keyLower, 'template');
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
$toneKey = match (true) { $toneKey = match (true) {
str_contains($keyLower, 'name') => 'name', str_contains($keyLower, 'name') => 'name',
str_contains($keyLower, 'platform') => 'platform', str_contains($keyLower, 'platform') => 'platform',
@ -88,6 +115,15 @@
$isBooleanValue = is_bool($value); $isBooleanValue = is_bool($value);
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true); $isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
$isNumericValue = is_numeric($value); $isNumericValue = is_numeric($value);
$badgeItems = null;
if ($isListValue) {
$badgeItems = $value;
} elseif (($isPlatform || $isTechnologies) && is_string($value)) {
$split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== ''));
$badgeItems = $split !== [] ? $split : [$value];
}
@endphp @endphp
<div class="tp-policy-general-card group relative overflow-hidden rounded-xl border border-gray-200/70 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-gray-300/70 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900 dark:hover:border-gray-600"> <div class="tp-policy-general-card group relative overflow-hidden rounded-xl border border-gray-200/70 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-gray-300/70 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900 dark:hover:border-gray-600">
@ -100,16 +136,50 @@
{{ $entry['key'] ?? '-' }} {{ $entry['key'] ?? '-' }}
</dt> </dt>
<dd class="mt-2 text-left"> <dd class="mt-2 text-left">
@if ($isListValue) @if ($isTemplateReference && is_array($value))
@php
$templateDisplayName = $value['templateDisplayName'] ?? null;
$templateFamily = $value['templateFamily'] ?? null;
$templateDisplayVersion = $value['templateDisplayVersion'] ?? null;
$templateId = $value['templateId'] ?? null;
$familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null;
@endphp
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
</div>
<div class="flex flex-wrap gap-2">
@if ($familyLabel)
<x-filament::badge color="gray" size="sm">{{ $familyLabel }}</x-filament::badge>
@endif
@if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '')
<x-filament::badge color="gray" size="sm">{{ $templateDisplayVersion }}</x-filament::badge>
@endif
</div>
@if (is_string($templateId) && $templateId !== '')
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 break-all">
{{ $templateId }}
</div>
@endif
</div>
@elseif ($isDateTime)
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">
{{ $formattedDateTime }}
</div>
@elseif (is_array($badgeItems) && $badgeItems !== [])
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@foreach ($value as $item) @foreach ($badgeItems as $item)
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm"> <x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
{{ $item }} {{ $item }}
</x-filament::badge> </x-filament::badge>
@endforeach @endforeach
</div> </div>
@elseif ($isJsonValue) @elseif ($isJsonValue)
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre> <pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
@elseif ($isBooleanValue || $isBooleanString) @elseif ($isBooleanValue || $isBooleanString)
@php @php
$boolValue = $isBooleanValue $boolValue = $isBooleanValue
@ -126,7 +196,7 @@
</div> </div>
@else @else
<div class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words text-left"> <div class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words text-left">
{{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }} {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
</div> </div>
@endif @endif
</dd> </dd>

View File

@ -7,6 +7,76 @@
$warnings = $state['warnings'] ?? []; $warnings = $state['warnings'] ?? [];
$settings = $state['settings'] ?? []; $settings = $state['settings'] ?? [];
$settingsTable = $state['settings_table'] ?? null; $settingsTable = $state['settings_table'] ?? null;
$policyType = $state['policy_type'] ?? null;
$stringifyValue = function (mixed $value): string {
if (is_null($value)) {
return 'N/A';
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
if (is_object($value)) {
if (method_exists($value, '__toString')) {
return (string) $value;
}
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
return 'N/A';
};
$shouldRenderBadges = function (mixed $value): bool {
if (! is_array($value) || $value === []) {
return false;
}
if (! array_is_list($value)) {
return false;
}
foreach ($value as $item) {
if (! is_scalar($item) && ! is_null($item)) {
return false;
}
}
return true;
};
$asEnabledDisabledBadgeValue = function (mixed $value): ?bool {
if (is_bool($value)) {
return $value;
}
if (! is_string($value)) {
return null;
}
$normalized = strtolower(trim($value));
return match ($normalized) {
'enabled', 'true', 'yes', '1' => true,
'disabled', 'false', 'no', '0' => false,
default => null,
};
};
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
@ -46,9 +116,13 @@
</dt> </dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2"> <dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white"> <span class="text-sm text-gray-900 dark:text-white">
@if(is_bool($row['value'])) @php
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm"> $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
{{ $row['value'] ? 'Enabled' : 'Disabled' }} @endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge> </x-filament::badge>
@elseif(is_numeric($row['value'])) @elseif(is_numeric($row['value']))
<span class="font-mono font-semibold">{{ $row['value'] }}</span> <span class="font-mono font-semibold">{{ $row['value'] }}</span>
@ -65,7 +139,11 @@
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}} {{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
@foreach($settings as $block) @foreach($settings as $block)
@if($block['type'] === 'table') @php
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
@endphp
@if($blockType === 'table')
<x-filament::section <x-filament::section
:heading="$block['title'] ?? 'Settings'" :heading="$block['title'] ?? 'Settings'"
collapsible collapsible
@ -79,24 +157,36 @@
<div class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($block['rows'] ?? [] as $row) @foreach($block['rows'] ?? [] as $row)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4"> <div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $row['label'] ?? $row['path'] ?? 'Setting' }} {{ $row['label'] ?? $row['path'] ?? 'Setting' }}
@if(!empty($row['description'])) @if(!empty($row['description']))
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
@endif @endif
</dt> </dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2"> <dd class="mt-1 sm:mt-0 sm:col-span-2">
@if(is_bool($row['value'])) @php
<x-filament::badge :color="$row['value'] ? 'success' : 'gray'" size="sm"> $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
{{ $row['value'] ? 'Enabled' : 'Disabled' }} @endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge> </x-filament::badge>
@elseif(is_numeric($row['value'])) @elseif(is_numeric($row['value']))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white"> <span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{{ $row['value'] }} {{ $row['value'] }}
</span> </span>
@elseif($shouldRenderBadges($row['value'] ?? null))
<div class="flex flex-wrap gap-1.5">
@foreach(($row['value'] ?? []) as $item)
<x-filament::badge color="gray" size="sm">
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
</x-filament::badge>
@endforeach
</div>
@else @else
<span class="text-sm text-gray-900 dark:text-white break-words"> <span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($row['value'] ?? 'N/A', 200) }} {{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
</span> </span>
@endif @endif
</dd> </dd>
@ -105,7 +195,7 @@
</div> </div>
</x-filament::section> </x-filament::section>
@elseif($block['type'] === 'keyValue') @elseif($blockType === 'keyValue')
<x-filament::section <x-filament::section
:heading="$block['title'] ?? 'Settings'" :heading="$block['title'] ?? 'Settings'"
collapsible collapsible
@ -123,9 +213,106 @@
{{ $entry['key'] }} {{ $entry['key'] }}
</dt> </dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2"> <dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white break-words"> @php
{{ Str::limit($entry['value'] ?? 'N/A', 200) }} $rawValue = $entry['value'] ?? null;
</span>
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
$badgeValue = $asEnabledDisabledBadgeValue($rawValue);
@endphp
@if($isScriptContent)
@php
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
$firstLine = strtok($code, "\n") ?: '';
$grammar = 'powershell';
if ($policyType === 'deviceShellScript') {
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
$grammar = 'zsh';
} elseif (str_contains($shebang, 'bash')) {
$grammar = 'bash';
} else {
$grammar = 'sh';
}
} else {
$grammar = 'sh';
}
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
$grammar = 'powershell';
}
$highlightedHtml = null;
if (class_exists(\Torchlight\Engine\Engine::class)) {
try {
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $grammar,
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
$highlightedHtml = null;
}
}
@endphp
<div x-data="{ open: false }" class="space-y-2">
<div class="flex items-center gap-2">
<x-filament::button
size="xs"
color="gray"
type="button"
x-on:click="open = !open"
>
<span x-show="!open" x-cloak>Show</span>
<span x-show="open" x-cloak>Hide</span>
</x-filament::button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format(Str::length($code)) }} chars
</span>
</div>
<div x-show="open" x-cloak>
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
@else
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
@endif
</div>
</div>
@elseif($shouldRenderBadges($rawValue))
<div class="flex flex-wrap gap-1.5">
@foreach(($rawValue ?? []) as $item)
<x-filament::badge color="gray" size="sm">
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
</x-filament::badge>
@endforeach
</div>
@elseif(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
</x-filament::badge>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($stringifyValue($rawValue), 200) }}
</span>
@endif
</dd> </dd>
</div> </div>
@endforeach @endforeach

View File

@ -268,10 +268,16 @@
@if (! empty($item['graph_error_code'])) @if (! empty($item['graph_error_code']))
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div> <div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
@endif @endif
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
<details class="mt-1"> <details class="mt-1">
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary> <summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800"> <div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
@if (! empty($item['graph_method']))
<div>method: {{ $item['graph_method'] }}</div>
@endif
@if (! empty($item['graph_path']))
<div>path: {{ $item['graph_path'] }}</div>
@endif
@if (! empty($item['graph_request_id'])) @if (! empty($item['graph_request_id']))
<div>request-id: {{ $item['graph_request_id'] }}</div> <div>request-id: {{ $item['graph_request_id'] }}</div>
@endif @endif

View File

@ -0,0 +1,48 @@
<x-filament::section>
<div class="grid gap-3">
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Scheduled for</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Status</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->status ?? '—' }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Started at</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->started_at)->toDateTimeString() ?? '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Finished at</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-sm font-medium">Error code</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_code ?: '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Backup set</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->backup_set_id ?: '—' }}</div>
</div>
</div>
<div>
<div class="text-sm font-medium">Error message</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_message ?: '—' }}</div>
</div>
<div>
<div class="text-sm font-medium">Summary</div>
<div class="rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
<pre class="whitespace-pre-wrap">{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
</div>
</div>
</x-filament::section>

View File

@ -0,0 +1,3 @@
<div class="space-y-4">
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
</div>

View File

@ -0,0 +1,13 @@
<style>
html.dark code.torchlight {
background-color: var(--phiki-dark-background-color) !important;
}
html.dark .phiki,
html.dark .phiki span {
color: var(--phiki-dark-color) !important;
font-style: var(--phiki-dark-font-style) !important;
font-weight: var(--phiki-dark-font-weight) !important;
text-decoration: var(--phiki-dark-text-decoration) !important;
}
</style>

View File

@ -0,0 +1,20 @@
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_assignments" class="fi-checkbox-input" />
<span class="text-sm">Include assignments</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_scope_tags" class="fi-checkbox-input" />
<span class="text-sm">Include scope tags</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_foundations" class="fi-checkbox-input" />
<span class="text-sm">Include foundations</span>
</label>
</div>
{{ $this->table }}
</div>

View File

@ -13,12 +13,13 @@
</h4> </h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($run->status === 'pending') @if($run->status === 'pending')
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
<span class="inline-flex items-center"> <span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
Starting... {{ $isStalePending ? 'Queued…' : 'Starting...' }}
</span> </span>
@elseif($run->status === 'running') @elseif($run->status === 'running')
<span class="inline-flex items-center"> <span class="inline-flex items-center">
@ -28,6 +29,10 @@
</svg> </svg>
Processing... Processing...
</span> </span>
@elseif(in_array($run->status, ['completed', 'completed_with_errors'], true))
<span class="text-success-600 dark:text-success-400">Done</span>
@elseif(in_array($run->status, ['failed', 'aborted'], true))
<span class="text-danger-600 dark:text-danger-400">Failed</span>
@endif @endif
</p> </p>
</div> </div>

View File

@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();

View File

@ -7,9 +7,12 @@ # Implementation Plan: Windows Update Rings (012)
## Summary ## Summary
Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament. Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament.
Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format.
## Execution Steps ## Execution Steps
1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete. 1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete.
2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload. 2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload.
3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune. 3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune.
4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI. 4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI.
5. **Tests + formatting**: Add targeted Pest tests for snapshot hydration, normalized display, and restore functionality. Run `./vendor/bin/pint --dirty` and the affected tests. 5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`.
6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests.

View File

@ -8,6 +8,10 @@ # Feature Specification: Windows Update Rings (012)
## Overview ## Overview
Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows. Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows.
This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`.
This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`.
This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type. This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type.
## In Scope ## In Scope
@ -17,6 +21,18 @@ ## In Scope
- Restore: Restore a Windows Update Ring policy from a snapshot. - Restore: Restore a Windows Update Ring policy from a snapshot.
- UI: Display the settings of a Windows Update Ring policy in a readable, normalized format. - UI: Display the settings of a Windows Update Ring policy in a readable, normalized format.
- Policy type: `windowsFeatureUpdateProfile`
- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`.
- Snapshot capture: Full snapshot of the Feature Update Profile payload.
- Restore: Restore a Feature Update Profile from a snapshot.
- UI: Display the key settings of a Feature Update Profile in a readable, normalized format.
- Policy type: `windowsQualityUpdateProfile`
- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`.
- Snapshot capture: Full snapshot of the Quality Update Profile payload.
- Restore: Restore a Quality Update Profile from a snapshot.
- UI: Display the key settings of a Quality Update Profile in a readable, normalized format.
## Out of Scope (v1) ## Out of Scope (v1)
- Advanced analytics or reporting on update compliance. - Advanced analytics or reporting on update compliance.
- Per-setting partial restore. - Per-setting partial restore.
@ -43,3 +59,19 @@ ### User Story 3 — Restore settings
**Acceptance** **Acceptance**
1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune. 1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune.
2. The restore process is audited. 2. The restore process is audited.
### User Story 4 — Feature Updates inventory + readable view
As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format.
**Acceptance**
1. Feature Update Profiles are listed in the main policy table with the correct type name.
2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window).
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.
### User Story 5 — Quality Updates inventory + readable view
As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format.
**Acceptance**
1. Quality Update Profiles are listed in the main policy table with the correct type name.
2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content).
3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view.

View File

@ -4,20 +4,23 @@ # Tasks: Windows Update Rings (012)
**Input**: [spec.md](./spec.md), [plan.md](./plan.md) **Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts + Snapshot Hydration ## Phase 1: Contracts + Snapshot Hydration
- [ ] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). - [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.).
- [ ] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. - [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings.
- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support.
## Phase 2: Restore ## Phase 2: Restore
- [ ] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. - [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`.
## Phase 3: UI Normalization ## Phase 3: UI Normalization
- [ ] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). - [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable).
- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable).
## Phase 4: Tests + Verification ## Phase 4: Tests + Verification
- [ ] T005 Add tests for hydration + UI display. - [X] T005 Add tests for sync filters + supported types.
- [ ] T006 Add tests for restore apply. - [X] T006 Add tests for restore apply.
- [ ] T007 Run tests (targeted). - [X] T007 Run tests (targeted).
- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`). - [X] T008 Run Pint (`./vendor/bin/pint --dirty`).
## Open TODOs (Follow-up) ## Open TODOs (Follow-up)
- None yet. - None yet.

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Scripts Management
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-01
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Assumptions: Supported script policy types are already discoverable in the product, and restore/assignments follow existing system patterns.

View File

@ -0,0 +1,42 @@
# Plan: Scripts Management (013)
**Branch**: `013-scripts-management`
**Date**: 2026-01-01
**Input**: [spec.md](./spec.md)
## Goal
Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments.
## Scope
### In scope
- Script policy types:
- `deviceManagementScript`
- `deviceShellScript`
- `deviceHealthScript`
- Readable “Normalized settings” output for the above types.
- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior).
- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata).
### Out of scope
- Adding new UI flows or pages.
- Introducing new external services or background infrastructure.
- Changing how authentication/authorization works.
## Approach
1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys).
2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure.
3. Register the normalizer in the application normalizer tag.
4. Add tests:
- Normalized output shape/stability for each type.
- Filament “Normalized settings” tab renders without errors for a version of each type.
5. Run targeted tests and Pint.
## Risks & Mitigations
- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed).
- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable.
## Success Criteria
- Normalized settings views are readable and stable for all three script policy types.
- Restore execution remains safe and assignment behavior is unchanged/regression-free.
- Tests cover the new normalizer behavior and basic UI render.

View File

@ -0,0 +1,112 @@
# Feature Specification: Scripts Management
**Feature Branch**: `013-scripts-management`
**Created**: 2026-01-01
**Status**: Draft
**Input**: User description: "Add end-to-end support for management scripts (Windows PowerShell scripts, macOS shell scripts, and proactive remediations) including readable normalized settings, backup snapshots, and safe restore with assignments."
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Restore a script safely (Priority: P1)
As an admin, I want to restore a script policy from a saved snapshot so I can recover from accidental or unwanted changes.
**Why this priority**: Restoring known-good configuration is the core safety value of the product.
**Independent Test**: Can be fully tested by restoring one script policy into a tenant where the script is missing or changed, and verifying the script and its assignments match the snapshot.
**Acceptance Scenarios**:
1. **Given** a saved script snapshot and a target tenant where the script does not exist, **When** I run restore for that item, **Then** the system creates a new script policy from the snapshot and reports success.
2. **Given** a saved script snapshot and a target tenant where the script exists with differences, **When** I run restore for that item, **Then** the system updates the existing script policy to match the snapshot and reports success.
3. **Given** a saved script snapshot with assignments, **When** I run restore, **Then** the system applies the assignments using the snapshot data and reports assignment outcomes.
---
### User Story 2 - Readable script configuration (Priority: P2)
As an admin, I want to view a readable, normalized representation of a script policy so I can understand what it does and compare versions reliably.
**Why this priority**: If admins cannot quickly understand changes, version history and restore become risky and slow.
**Independent Test**: Can be tested by opening a script policy version page and confirming that normalized settings display key fields consistently across versions.
**Acceptance Scenarios**:
1. **Given** a script policy version, **When** I open the policy version details, **Then** I see a normalized settings view that is stable (same input yields same output ordering/shape).
2. **Given** two versions of the same script policy with changes, **When** I view their normalized settings, **Then** the differences are visible without reading raw JSON.
---
### User Story 3 - Reliable backup capture (Priority: P3)
As an admin, I want backups/version snapshots of script policies to be captured reliably so I can restore later with confidence.
**Why this priority**: Restore is only as good as the snapshot quality.
**Independent Test**: Can be tested by capturing a snapshot of each script policy type and validating it contains the expected configuration fields for that policy.
**Acceptance Scenarios**:
1. **Given** an existing script policy, **When** I capture a snapshot/backup, **Then** the saved snapshot contains the complete configuration needed to restore the script policy.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
- Restoring a snapshot whose policy type does not match the target item (type mismatch) must fail clearly without making changes.
- Restoring when the snapshot contains fields that are not accepted by the target environment must result in a clear failure reason and no partial silent data loss.
- Assignments referencing groups or foundations that cannot be mapped must be reported as manual-required for those assignments.
- Script policies with very large or complex configuration should still render a readable normalized settings view (with safe truncation if needed).
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST support listing and viewing script policies for the supported script policy types.
- **FR-002**: System MUST allow capturing a snapshot of a script policy that is sufficient to restore the policy later.
- **FR-003**: System MUST allow restoring a script policy from a snapshot in a safe manner (create when missing; update when present).
- **FR-004**: System MUST support restoring assignments for script policies using the assignments saved with the snapshot.
- **FR-005**: System MUST present a readable normalized settings view for script policies and script policy versions.
- **FR-006**: System MUST prevent execution of restore if the snapshot policy type does not match the restore item type.
- **FR-007**: System MUST record an audit trail for restore preview and restore execution attempts.
### Key Entities *(include if feature involves data)*
- **Script Policy**: A configuration object representing a management script (platform-specific variants), identified by a stable external identifier and a display name.
- **Script Policy Snapshot**: An immutable capture of a script policys configuration at a point in time, used for diffing and restore.
- **Script Assignment**: A target association that applies a script policy to a defined scope (e.g., groups/filters), stored with the snapshot and restored with mapping when needed.
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: An admin can complete a restore preview for a single script policy in under 1 minute.
- **SC-002**: In a test tenant, restoring a script policy results in the target script policy and assignments matching the snapshot for 100% of supported script policy types.
- **SC-003**: Normalized settings for a script policy are readable and stable: repeated views of the same snapshot produce identical normalized output.
- **SC-004**: Restore failures provide a clear reason (actionable message) in 100% of failure cases.

View File

@ -0,0 +1,28 @@
# Tasks: Scripts Management (013)
**Branch**: `013-scripts-management` | **Date**: 2026-01-01
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts Review
- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key).
## Phase 2: UI Normalization
- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types.
- [x] T003 Register the normalizer in `AppServiceProvider`.
## Phase 3: Tests + Verification
- [x] T004 Add tests for normalized output (shape + stability) for each script policy type.
- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type.
- [x] T006 Run targeted tests.
- [x] T007 Run Pint (`./vendor/bin/pint --dirty`).
## Phase 4: Script Content Display (Safe)
- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings.
- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default).
- [x] T010 Hide script content behind a Show/Hide button (collapsed by default).
- [x] T011 Highlight script content in Normalized Diff view (From/To).
- [x] T012 Enable Torchlight highlighting in Diff + Before/After views.
- [x] T013 Add “Fullscreen” overlay for script diffs (scroll sync).
## Open TODOs (Follow-up)
- None yet.

Some files were not shown because too many files have changed in this diff Show More